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'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
NEXTCLOUD_URL: ${{ vars.NEXTCLOUD_URL }}
|
||||||
|
NEXTCLOUD_USER: ${{ secrets.NEXTCLOUD_USER }}
|
||||||
|
NEXTCLOUD_PASS: ${{ secrets.NEXTCLOUD_PASS }}
|
||||||
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
||||||
|
|
||||||
- name: Upload APKs
|
- name: Upload APKs
|
||||||
|
|
|
@ -44,6 +44,10 @@ android_app {
|
||||||
"seedvault-lib-koin-android",
|
"seedvault-lib-koin-android",
|
||||||
// bip39
|
// bip39
|
||||||
"seedvault-lib-kotlin-bip39",
|
"seedvault-lib-kotlin-bip39",
|
||||||
|
// WebDAV
|
||||||
|
"seedvault-lib-dav4jvm",
|
||||||
|
"seedvault-lib-okhttp",
|
||||||
|
"seedvault-lib-okio",
|
||||||
],
|
],
|
||||||
manifest: "app/src/main/AndroidManifest.xml",
|
manifest: "app/src/main/AndroidManifest.xml",
|
||||||
|
|
||||||
|
|
|
@ -159,6 +159,9 @@ dependencies {
|
||||||
|
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||||
|
|
||||||
|
// dav4jvm - later versions of okhttp need kotlin > 1.9.0
|
||||||
|
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Dependencies (do not concern the AOSP build)
|
* Test Dependencies (do not concern the AOSP build)
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -41,19 +41,20 @@ class KoinInstrumentationTestApp : App() {
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
currentRestoreViewModel =
|
currentRestoreViewModel =
|
||||||
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get()))
|
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
|
||||||
currentRestoreViewModel!!
|
currentRestoreViewModel!!
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
currentBackupStorageViewModel =
|
val viewModel =
|
||||||
spyk(BackupStorageViewModel(context, get(), get(), get(), get()))
|
BackupStorageViewModel(context, get(), get(), get(), get(), get(), get(), get())
|
||||||
|
currentBackupStorageViewModel = spyk(viewModel)
|
||||||
currentBackupStorageViewModel!!
|
currentBackupStorageViewModel!!
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
currentRestoreStorageViewModel =
|
currentRestoreStorageViewModel =
|
||||||
spyk(RestoreStorageViewModel(context, get(), get()))
|
spyk(RestoreStorageViewModel(context, get(), get(), get(), get()))
|
||||||
currentRestoreStorageViewModel!!
|
currentRestoreStorageViewModel!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
|
@ -28,20 +29,24 @@ import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
@MediumTest
|
@MediumTest
|
||||||
class PluginTest : KoinComponent {
|
class PluginTest : KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
private val mockedSettingsManager: SettingsManager = mockk()
|
private val mockedSettingsManager: SettingsManager = mockk()
|
||||||
private val storage = DocumentsStorage(context, mockedSettingsManager)
|
private val storage = DocumentsStorage(
|
||||||
|
appContext = context,
|
||||||
|
settingsManager = mockedSettingsManager,
|
||||||
|
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
||||||
|
)
|
||||||
|
|
||||||
private val storagePlugin: StoragePlugin = DocumentsProviderStoragePlugin(context, storage)
|
private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin =
|
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
||||||
DocumentsProviderLegacyPlugin(context, storage)
|
storage
|
||||||
|
}
|
||||||
|
|
||||||
private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L
|
private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L
|
||||||
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
||||||
|
@ -49,7 +54,7 @@ class PluginTest : KoinComponent {
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() = runBlocking {
|
fun setup() = runBlocking {
|
||||||
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
|
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
||||||
storage.rootBackupDir?.deleteContents(context)
|
storage.rootBackupDir?.deleteContents(context)
|
||||||
?: error("Select a storage location in the app first!")
|
?: error("Select a storage location in the app first!")
|
||||||
}
|
}
|
||||||
|
@ -76,8 +81,6 @@ class PluginTest : KoinComponent {
|
||||||
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||||
// no backups available initially
|
// no backups available initially
|
||||||
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
|
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
val s = settingsManager.getStorage() ?: error("no storage")
|
|
||||||
assertFalse(storagePlugin.hasBackup(s))
|
|
||||||
|
|
||||||
// prepare returned tokens requested when initializing device
|
// prepare returned tokens requested when initializing device
|
||||||
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||||
|
@ -92,7 +95,6 @@ class PluginTest : KoinComponent {
|
||||||
|
|
||||||
// one backup available now
|
// one backup available now
|
||||||
assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
|
assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
assertTrue(storagePlugin.hasBackup(s))
|
|
||||||
|
|
||||||
// initializing again (with another restore set) does add a restore set
|
// initializing again (with another restore set) does add a restore set
|
||||||
storagePlugin.startNewRestoreSet(token + 1)
|
storagePlugin.startNewRestoreSet(token + 1)
|
||||||
|
@ -100,7 +102,6 @@ class PluginTest : KoinComponent {
|
||||||
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
assertTrue(storagePlugin.hasBackup(s))
|
|
||||||
|
|
||||||
// initializing again (without new restore set) doesn't change number of restore sets
|
// initializing again (without new restore set) doesn't change number of restore sets
|
||||||
storagePlugin.initializeDevice()
|
storagePlugin.initializeDevice()
|
||||||
|
|
|
@ -22,7 +22,6 @@ import com.stevesoltys.seedvault.e2e.screen.impl.DocumentPickerScreen
|
||||||
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
|
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
|
||||||
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
@ -69,8 +68,6 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
val keyManager: KeyManager get() = get()
|
val keyManager: KeyManager get() = get()
|
||||||
|
|
||||||
val documentsStorage: DocumentsStorage get() = get()
|
|
||||||
|
|
||||||
val spyMetadataManager: MetadataManager get() = get()
|
val spyMetadataManager: MetadataManager get() = get()
|
||||||
|
|
||||||
val backupManager: IBackupManager get() = get()
|
val backupManager: IBackupManager get() = get()
|
||||||
|
@ -84,7 +81,6 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
fun resetApplicationState() {
|
fun resetApplicationState() {
|
||||||
backupManager.setAutoRestore(false)
|
backupManager.setAutoRestore(false)
|
||||||
settingsManager.setNewToken(null)
|
settingsManager.setNewToken(null)
|
||||||
documentsStorage.reset(null)
|
|
||||||
|
|
||||||
val sharedPreferences = permitDiskReads {
|
val sharedPreferences = permitDiskReads {
|
||||||
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
||||||
|
|
|
@ -17,7 +17,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
||||||
confirmCode()
|
confirmCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsManager.getStorage() == null) {
|
if (settingsManager.getSafStorage() == null) {
|
||||||
chooseStorageLocation()
|
chooseStorageLocation()
|
||||||
} else {
|
} else {
|
||||||
changeBackupLocation()
|
changeBackupLocation()
|
||||||
|
|
|
@ -39,13 +39,16 @@ import java.io.IOException
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
@MediumTest
|
@MediumTest
|
||||||
class DocumentsStorageTest : KoinComponent {
|
class DocumentsStorageTest : KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val settingsManager by inject<SettingsManager>()
|
private val settingsManager by inject<SettingsManager>()
|
||||||
private val storage = DocumentsStorage(context, settingsManager)
|
private val storage = DocumentsStorage(
|
||||||
|
appContext = context,
|
||||||
|
settingsManager = settingsManager,
|
||||||
|
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
||||||
|
)
|
||||||
|
|
||||||
private val filename = getRandomBase64()
|
private val filename = getRandomBase64()
|
||||||
private lateinit var file: DocumentFile
|
private lateinit var file: DocumentFile
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.util.Log
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.AppStatus
|
import com.stevesoltys.seedvault.settings.AppStatus
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -24,7 +25,9 @@ class PackageServiceTest : KoinComponent {
|
||||||
|
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
|
||||||
private val storagePlugin: StoragePlugin by inject()
|
private val storagePluginManager: StoragePluginManager by inject()
|
||||||
|
|
||||||
|
private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testNotAllowedPackages() {
|
fun testNotAllowedPackages() {
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
<!-- This is needed to check for internet access when backup is stored on network storage -->
|
<!-- This is needed to check for internet access when backup is stored on network storage -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Used for internal WebDAV plugin -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<!-- This is needed to inform users about backup status and errors -->
|
<!-- This is needed to inform users about backup status and errors -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
@ -97,7 +100,8 @@
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.storage.StorageActivity"
|
android:name=".ui.storage.StorageActivity"
|
||||||
android:theme="@style/AppTheme.NoActionBar" />
|
android:theme="@style/AppTheme.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.storage.PermissionGrantActivity"
|
android:name=".ui.storage.PermissionGrantActivity"
|
||||||
|
|
|
@ -13,13 +13,15 @@ import android.os.StrictMode
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
import android.os.UserManager
|
import android.os.UserManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||||
|
import androidx.work.WorkManager
|
||||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||||
import com.stevesoltys.seedvault.header.headerModule
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||||
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
|
||||||
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
||||||
import com.stevesoltys.seedvault.restore.install.installModule
|
import com.stevesoltys.seedvault.restore.install.installModule
|
||||||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||||
|
@ -54,15 +56,41 @@ open class App : Application() {
|
||||||
private val appModule = module {
|
private val appModule = module {
|
||||||
single { SettingsManager(this@App) }
|
single { SettingsManager(this@App) }
|
||||||
single { BackupNotificationManager(this@App) }
|
single { BackupNotificationManager(this@App) }
|
||||||
|
single { StoragePluginManager(this@App, get(), get(), get()) }
|
||||||
|
single { BackupStateManager(this@App) }
|
||||||
single { Clock() }
|
single { Clock() }
|
||||||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||||
factory { AppListRetriever(this@App, get(), get(), get()) }
|
factory { AppListRetriever(this@App, get(), get(), get()) }
|
||||||
|
|
||||||
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
|
viewModel {
|
||||||
|
SettingsViewModel(
|
||||||
|
app = this@App,
|
||||||
|
settingsManager = get(),
|
||||||
|
keyManager = get(),
|
||||||
|
pluginManager = get(),
|
||||||
|
metadataManager = get(),
|
||||||
|
appListRetriever = get(),
|
||||||
|
storageBackup = get(),
|
||||||
|
backupManager = get(),
|
||||||
|
backupInitializer = get(),
|
||||||
|
backupStateManager = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) }
|
viewModel {
|
||||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
BackupStorageViewModel(
|
||||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
app = this@App,
|
||||||
|
backupManager = get(),
|
||||||
|
backupInitializer = get(),
|
||||||
|
storageBackup = get(),
|
||||||
|
safHandler = get(),
|
||||||
|
webDavHandler = get(),
|
||||||
|
settingsManager = get(),
|
||||||
|
storagePluginManager = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
||||||
|
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { FileSelectionViewModel(this@App, get()) }
|
viewModel { FileSelectionViewModel(this@App, get()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +128,8 @@ open class App : Application() {
|
||||||
cryptoModule,
|
cryptoModule,
|
||||||
headerModule,
|
headerModule,
|
||||||
metadataModule,
|
metadataModule,
|
||||||
documentsProviderModule, // storage plugin
|
storagePluginModuleSaf,
|
||||||
|
storagePluginModuleWebDav,
|
||||||
backupModule,
|
backupModule,
|
||||||
restoreModule,
|
restoreModule,
|
||||||
installModule,
|
installModule,
|
||||||
|
|
|
@ -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
|
package com.stevesoltys.seedvault.plugins
|
||||||
|
|
||||||
import android.app.backup.RestoreSet
|
import android.app.backup.RestoreSet
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
interface StoragePlugin {
|
interface StoragePlugin<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the plugin is working, or false if it isn't.
|
||||||
|
* @throws Exception any kind of exception to provide more info on the error
|
||||||
|
*/
|
||||||
|
suspend fun test(): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new [RestoreSet] with the given token.
|
* Start a new [RestoreSet] with the given token.
|
||||||
|
@ -47,14 +51,6 @@ interface StoragePlugin {
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun removeData(token: Long, name: String)
|
suspend fun removeData(token: Long, name: String)
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches if there's really a backup available in the given storage location.
|
|
||||||
* Returns true if at least one was found and false otherwise.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun hasBackup(storage: Storage): Boolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the set of all backups currently available for restore.
|
* Get the set of all backups currently available for restore.
|
||||||
*
|
*
|
||||||
|
@ -75,4 +71,7 @@ interface StoragePlugin {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream)
|
class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream)
|
||||||
|
|
||||||
|
internal val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
|
||||||
|
internal val chunkFolderRegex = Regex("[a-f0-9]{2}")
|
||||||
|
|
|
@ -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
|
import java.io.InputStream
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Suppress("BlockingMethodInNonBlockingContext", "Deprecation") // all methods do I/O
|
@Suppress("Deprecation")
|
||||||
internal class DocumentsProviderLegacyPlugin(
|
internal class DocumentsProviderLegacyPlugin(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val storage: DocumentsStorage,
|
private val storageGetter: () -> DocumentsStorage,
|
||||||
) : LegacyStoragePlugin {
|
) : LegacyStoragePlugin {
|
||||||
|
|
||||||
|
private val storage get() = storageGetter()
|
||||||
private var packageDir: DocumentFile? = null
|
private var packageDir: DocumentFile? = null
|
||||||
private var packageChildren: List<DocumentFile>? = null
|
private var packageChildren: List<DocumentFile>? = null
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val documentsProviderModule = module {
|
val storagePluginModuleSaf = module {
|
||||||
single { DocumentsStorage(androidContext(), get()) }
|
single { SafFactory(androidContext(), get(), get()) }
|
||||||
|
single { SafHandler(androidContext(), get(), get(), get()) }
|
||||||
|
|
||||||
single<StoragePlugin> { DocumentsProviderStoragePlugin(androidContext(), get()) }
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
single<LegacyStoragePlugin> { DocumentsProviderLegacyPlugin(androidContext(), get()) }
|
single<LegacyStoragePlugin> {
|
||||||
|
DocumentsProviderLegacyPlugin(
|
||||||
|
context = androidContext(),
|
||||||
|
storageGetter = {
|
||||||
|
val safStorage = get<SettingsManager>().getSafStorage() ?: error("No SAF storage")
|
||||||
|
DocumentsStorage(androidContext(), get(), safStorage)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,15 @@ package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
||||||
|
import com.stevesoltys.seedvault.plugins.tokenRegex
|
||||||
|
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
@ -15,22 +18,23 @@ import java.io.OutputStream
|
||||||
|
|
||||||
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class DocumentsProviderStoragePlugin(
|
internal class DocumentsProviderStoragePlugin(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
private val storage: DocumentsStorage,
|
||||||
) : StoragePlugin {
|
) : StoragePlugin<Uri> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attention: This context might be from a different user. Use with care.
|
* Attention: This context might be from a different user. Use with care.
|
||||||
*/
|
*/
|
||||||
private val context: Context
|
private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
||||||
get() = appContext.getStorageContext {
|
|
||||||
storage.storage?.isUsb == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val packageManager: PackageManager = appContext.packageManager
|
private val packageManager: PackageManager = appContext.packageManager
|
||||||
|
|
||||||
|
override suspend fun test(): Boolean {
|
||||||
|
val dir = storage.rootBackupDir
|
||||||
|
return dir != null && dir.exists()
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun startNewRestoreSet(token: Long) {
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
// reset current storage
|
// reset current storage
|
||||||
|
@ -71,16 +75,6 @@ internal class DocumentsProviderStoragePlugin(
|
||||||
if (!file.delete()) throw IOException("Failed to delete $name")
|
if (!file.delete()) throw IOException("Failed to delete $name")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override suspend fun hasBackup(storage: Storage): Boolean {
|
|
||||||
// potentially get system user context if needed here
|
|
||||||
val c = appContext.getStorageContext { storage.isUsb }
|
|
||||||
val parent = DocumentFile.fromTreeUri(c, storage.uri) ?: throw AssertionError()
|
|
||||||
val rootDir = parent.findFileBlocking(c, DIRECTORY_ROOT) ?: return false
|
|
||||||
val backupSets = getBackups(c, rootDir)
|
|
||||||
return backupSets.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
val rootDir = storage.rootBackupDir ?: return null
|
val rootDir = storage.rootBackupDir ?: return null
|
||||||
val backupSets = getBackups(context, rootDir)
|
val backupSets = getBackups(context, rootDir)
|
||||||
|
@ -104,7 +98,6 @@ internal class DocumentsProviderStoragePlugin(
|
||||||
|
|
||||||
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
||||||
val backupSets = ArrayList<BackupSet>()
|
val backupSets = ArrayList<BackupSet>()
|
||||||
val files = try {
|
val files = try {
|
||||||
|
@ -137,9 +130,6 @@ internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<B
|
||||||
return backupSets
|
return backupSets
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
|
|
||||||
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
|
|
||||||
|
|
||||||
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
||||||
val looksLikeToken = name != null && tokenRegex.matches(name)
|
val looksLikeToken = name != null && tokenRegex.matches(name)
|
||||||
// check for isDirectory only if we already have a valid token (causes DB query)
|
// check for isDirectory only if we already have a valid token (causes DB query)
|
||||||
|
@ -160,5 +150,5 @@ private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
||||||
private fun isUnexpectedFile(name: String): Boolean {
|
private fun isUnexpectedFile(name: String): Boolean {
|
||||||
return name != FILE_NO_MEDIA &&
|
return name != FILE_NO_MEDIA &&
|
||||||
!chunkFolderRegex.matches(name) &&
|
!chunkFolderRegex.matches(name) &&
|
||||||
!name.endsWith(".SeedSnap")
|
!name.endsWith(SNAPSHOT_EXT)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
@ -18,7 +16,6 @@ import androidx.annotation.VisibleForTesting
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
@ -44,27 +41,19 @@ private val TAG = DocumentsStorage::class.java.simpleName
|
||||||
internal class DocumentsStorage(
|
internal class DocumentsStorage(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
|
internal val safStorage: SafStorage,
|
||||||
) {
|
) {
|
||||||
internal var storage: Storage? = null
|
|
||||||
get() {
|
|
||||||
if (field == null) field = settingsManager.getStorage()
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attention: This context might be from a different user. Use with care.
|
* Attention: This context might be from a different user. Use with care.
|
||||||
*/
|
*/
|
||||||
private val context: Context
|
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
|
||||||
get() = appContext.getStorageContext {
|
|
||||||
storage?.isUsb == true
|
|
||||||
}
|
|
||||||
private val contentResolver: ContentResolver get() = context.contentResolver
|
private val contentResolver: ContentResolver get() = context.contentResolver
|
||||||
|
|
||||||
internal var rootBackupDir: DocumentFile? = null
|
internal var rootBackupDir: DocumentFile? = null
|
||||||
get() = runBlocking {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
val parent = storage?.getDocumentFile(context)
|
val parent = safStorage.getDocumentFile(context)
|
||||||
?: return@runBlocking null
|
|
||||||
field = try {
|
field = try {
|
||||||
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
||||||
// create .nomedia file to prevent Android's MediaScanner
|
// create .nomedia file to prevent Android's MediaScanner
|
||||||
|
@ -104,13 +93,12 @@ internal class DocumentsStorage(
|
||||||
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
|
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
|
||||||
*/
|
*/
|
||||||
fun reset(newToken: Long?) {
|
fun reset(newToken: Long?) {
|
||||||
storage = null
|
|
||||||
currentToken = newToken
|
currentToken = newToken
|
||||||
rootBackupDir = null
|
rootBackupDir = null
|
||||||
currentSetDir = null
|
currentSetDir = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAuthority(): String? = storage?.uri?.authority
|
fun getAuthority(): String? = safStorage.uri.authority
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||||
|
|
|
@ -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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
@ -9,8 +14,13 @@ import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.StorageOption
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon
|
|
||||||
|
|
||||||
private const val DAVX5_PACKAGE = "at.bitfire.davdroid"
|
private const val DAVX5_PACKAGE = "at.bitfire.davdroid"
|
||||||
private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity"
|
private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity"
|
||||||
|
@ -29,15 +39,15 @@ internal class SafStorageOptions(
|
||||||
|
|
||||||
private val packageManager = context.packageManager
|
private val packageManager = context.packageManager
|
||||||
|
|
||||||
internal fun checkOrAddExtraRoots(roots: ArrayList<SafOption>) {
|
internal fun checkOrAddExtraRoots(roots: ArrayList<StorageOption>) {
|
||||||
checkOrAddUsbRoot(roots)
|
checkOrAddUsbRoot(roots)
|
||||||
checkOrAddDavX5Root(roots)
|
checkOrAddDavX5Root(roots)
|
||||||
checkOrAddNextCloudRoot(roots)
|
checkOrAddNextCloudRoot(roots)
|
||||||
checkOrAddRoundSyncRoots(roots)
|
checkOrAddRoundSyncRoots(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkOrAddUsbRoot(roots: ArrayList<SafOption>) {
|
private fun checkOrAddUsbRoot(roots: ArrayList<StorageOption>) {
|
||||||
if (doNotInclude(AUTHORITY_STORAGE, roots) { it.isUsb }) return
|
if (doNotInclude(AUTHORITY_STORAGE, roots) { it is SafOption && it.isUsb }) return
|
||||||
|
|
||||||
val root = SafOption(
|
val root = SafOption(
|
||||||
authority = AUTHORITY_STORAGE,
|
authority = AUTHORITY_STORAGE,
|
||||||
|
@ -57,11 +67,11 @@ internal class SafStorageOptions(
|
||||||
/**
|
/**
|
||||||
* Add a storage root for each child directory at the RoundSync root, if it exists.
|
* Add a storage root for each child directory at the RoundSync root, if it exists.
|
||||||
*/
|
*/
|
||||||
private fun checkOrAddRoundSyncRoots(roots: ArrayList<SafOption>) {
|
private fun checkOrAddRoundSyncRoots(roots: ArrayList<StorageOption>) {
|
||||||
|
|
||||||
val roundSyncRoot = roots.firstOrNull {
|
val roundSyncRoot = roots.firstOrNull {
|
||||||
it.authority == AUTHORITY_ROUND_SYNC
|
it is SafOption && it.authority == AUTHORITY_ROUND_SYNC
|
||||||
} ?: return
|
} as? SafOption ?: return
|
||||||
|
|
||||||
roots.remove(roundSyncRoot)
|
roots.remove(roundSyncRoot)
|
||||||
|
|
||||||
|
@ -105,7 +115,7 @@ internal class SafStorageOptions(
|
||||||
*
|
*
|
||||||
* If it *is* installed and this is restore, the user can set up a new account by clicking.
|
* If it *is* installed and this is restore, the user can set up a new account by clicking.
|
||||||
*/
|
*/
|
||||||
private fun checkOrAddDavX5Root(roots: ArrayList<SafOption>) {
|
private fun checkOrAddDavX5Root(roots: ArrayList<StorageOption>) {
|
||||||
if (doNotInclude(AUTHORITY_DAVX5, roots)) return
|
if (doNotInclude(AUTHORITY_DAVX5, roots)) return
|
||||||
|
|
||||||
val intent = Intent().apply {
|
val intent = Intent().apply {
|
||||||
|
@ -155,7 +165,7 @@ internal class SafStorageOptions(
|
||||||
* because we don't know if there's just no account or an activated passcode
|
* because we don't know if there's just no account or an activated passcode
|
||||||
* (which hides existing accounts).
|
* (which hides existing accounts).
|
||||||
*/
|
*/
|
||||||
private fun checkOrAddNextCloudRoot(roots: ArrayList<SafOption>) {
|
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageOption>) {
|
||||||
if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return
|
if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return
|
||||||
|
|
||||||
val intent = Intent().apply {
|
val intent = Intent().apply {
|
||||||
|
@ -202,11 +212,12 @@ internal class SafStorageOptions(
|
||||||
|
|
||||||
private fun doNotInclude(
|
private fun doNotInclude(
|
||||||
authority: String,
|
authority: String,
|
||||||
roots: ArrayList<SafOption>,
|
roots: ArrayList<StorageOption>,
|
||||||
doNotIncludeIfTrue: ((SafOption) -> Boolean)? = null,
|
doNotIncludeIfTrue: ((StorageOption) -> Boolean)? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!isAuthoritySupported(authority)) return true
|
if (!isAuthoritySupported(authority)) return true
|
||||||
for (root in roots) {
|
for (root in roots) {
|
||||||
|
if (root !is SafOption) continue
|
||||||
if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) {
|
if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
|
@ -1,4 +1,9 @@
|
||||||
package com.stevesoltys.seedvault.ui.storage
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.Manifest.permission.MANAGE_DOCUMENTS
|
import android.Manifest.permission.MANAGE_DOCUMENTS
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -19,8 +24,16 @@ import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB
|
||||||
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
|
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
|
||||||
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DOWNLOADS
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_HOME
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||||
|
|
||||||
internal object StorageRootResolver {
|
internal object StorageRootResolver {
|
||||||
|
@ -126,23 +139,23 @@ internal object StorageRootResolver {
|
||||||
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
||||||
return getPackageIcon(context, authority, icon) ?: when {
|
return getPackageIcon(context, authority, icon) ?: when {
|
||||||
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
||||||
context.getDrawable(R.drawable.ic_phone_android)
|
getDrawable(context, R.drawable.ic_phone_android)
|
||||||
}
|
}
|
||||||
|
|
||||||
authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
|
authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
|
||||||
context.getDrawable(R.drawable.ic_usb)
|
getDrawable(context, R.drawable.ic_usb)
|
||||||
}
|
}
|
||||||
|
|
||||||
authority == AUTHORITY_NEXTCLOUD -> {
|
authority == AUTHORITY_NEXTCLOUD -> {
|
||||||
context.getDrawable(R.drawable.nextcloud)
|
getDrawable(context, R.drawable.nextcloud)
|
||||||
}
|
}
|
||||||
|
|
||||||
authority == AUTHORITY_DAVX5 -> {
|
authority == AUTHORITY_DAVX5 -> {
|
||||||
context.getDrawable(R.drawable.davx5)
|
getDrawable(context, R.drawable.davx5)
|
||||||
}
|
}
|
||||||
|
|
||||||
authority == AUTHORITY_ROUND_SYNC -> {
|
authority == AUTHORITY_ROUND_SYNC -> {
|
||||||
context.getDrawable(R.drawable.round_sync)
|
getDrawable(context, R.drawable.round_sync)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
|
@ -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.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||||
|
@ -81,8 +82,9 @@ internal class RestoreViewModel(
|
||||||
private val restoreCoordinator: RestoreCoordinator,
|
private val restoreCoordinator: RestoreCoordinator,
|
||||||
private val apkRestore: ApkRestore,
|
private val apkRestore: ApkRestore,
|
||||||
storageBackup: StorageBackup,
|
storageBackup: StorageBackup,
|
||||||
|
pluginManager: StoragePluginManager,
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager),
|
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
|
||||||
RestorableBackupClickListener, SnapshotViewModel {
|
RestorableBackupClickListener, SnapshotViewModel {
|
||||||
|
|
||||||
override val isRestoreOperation = true
|
override val isRestoreOperation = true
|
||||||
|
|
|
@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
|
@ -28,7 +29,7 @@ private val TAG = ApkRestore::class.java.simpleName
|
||||||
|
|
||||||
internal class ApkRestore(
|
internal class ApkRestore(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val storagePlugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
|
@ -37,6 +38,7 @@ internal class ApkRestore(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val pm = context.packageManager
|
private val pm = context.packageManager
|
||||||
|
private val storagePlugin get() = pluginManager.appPlugin
|
||||||
|
|
||||||
fun restore(backup: RestorableBackup) = flow {
|
fun restore(backup: RestorableBackup) = flow {
|
||||||
// we don't filter out apps without APK, so the user can manually install them
|
// we don't filter out apps without APK, so the user can manually install them
|
||||||
|
@ -87,7 +89,7 @@ internal class ApkRestore(
|
||||||
emit(installResult)
|
emit(installResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
|
@Suppress("ThrowsCount")
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
private suspend fun restore(
|
private suspend fun restore(
|
||||||
collector: FlowCollector<InstallResult>,
|
collector: FlowCollector<InstallResult>,
|
||||||
|
@ -212,7 +214,6 @@ internal class ApkRestore(
|
||||||
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
|
|
||||||
private suspend fun cacheApk(
|
private suspend fun cacheApk(
|
||||||
version: Byte,
|
version: Byte,
|
||||||
token: Long,
|
token: Long,
|
||||||
|
|
|
@ -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 androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
|
||||||
|
|
||||||
private val viewModel: SettingsViewModel by sharedViewModel()
|
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val storagePluginManager: StoragePluginManager by inject()
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
permitDiskReads {
|
permitDiskReads {
|
||||||
|
@ -29,7 +31,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val storage = settingsManager.getStorage()
|
val storage = storagePluginManager.storageProperties
|
||||||
if (storage?.isUsb == true) {
|
if (storage?.isUsb == true) {
|
||||||
findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
|
findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,8 @@ import androidx.preference.TwoStatePreference
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.StorageProperties
|
||||||
import com.stevesoltys.seedvault.restore.RestoreActivity
|
import com.stevesoltys.seedvault.restore.RestoreActivity
|
||||||
import com.stevesoltys.seedvault.ui.toRelativeTime
|
import com.stevesoltys.seedvault.ui.toRelativeTime
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
@ -33,7 +35,7 @@ private val TAG = SettingsFragment::class.java.name
|
||||||
class SettingsFragment : PreferenceFragmentCompat() {
|
class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private val viewModel: SettingsViewModel by sharedViewModel()
|
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val storagePluginManager: StoragePluginManager by inject()
|
||||||
private val backupManager: IBackupManager by inject()
|
private val backupManager: IBackupManager by inject()
|
||||||
|
|
||||||
private lateinit var backup: TwoStatePreference
|
private lateinit var backup: TwoStatePreference
|
||||||
|
@ -48,7 +50,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
private var menuBackupNow: MenuItem? = null
|
private var menuBackupNow: MenuItem? = null
|
||||||
private var menuRestore: MenuItem? = null
|
private var menuRestore: MenuItem? = null
|
||||||
|
|
||||||
private var storage: Storage? = null
|
private val storageProperties: StorageProperties<*>?
|
||||||
|
get() = storagePluginManager.storageProperties
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
permitDiskReads {
|
permitDiskReads {
|
||||||
|
@ -88,9 +91,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
backupLocation = findPreference("backup_location")!!
|
backupLocation = findPreference("backup_location")!!
|
||||||
backupLocation.setOnPreferenceClickListener {
|
backupLocation.setOnPreferenceClickListener {
|
||||||
|
if (viewModel.isBackupRunning.value) {
|
||||||
|
// don't allow changing backup destination while backup is running
|
||||||
|
// TODO we could show toast or snackbar here
|
||||||
|
false
|
||||||
|
} else {
|
||||||
viewModel.chooseBackupLocation()
|
viewModel.chooseBackupLocation()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
|
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
|
||||||
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||||
|
@ -148,7 +157,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
setAppBackupStatusSummary(time)
|
setAppBackupStatusSummary(time)
|
||||||
}
|
}
|
||||||
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
|
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
|
||||||
viewModel.onWorkerStateChanged()
|
|
||||||
setAppBackupSchedulingSummary(workInfo)
|
setAppBackupSchedulingSummary(workInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,7 +172,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
// we need to re-set the title when returning to this fragment
|
// we need to re-set the title when returning to this fragment
|
||||||
activity?.setTitle(R.string.backup)
|
activity?.setTitle(R.string.backup)
|
||||||
|
|
||||||
storage = settingsManager.getStorage()
|
|
||||||
setBackupEnabledState()
|
setBackupEnabledState()
|
||||||
setBackupLocationSummary()
|
setBackupLocationSummary()
|
||||||
setAutoRestoreState()
|
setAutoRestoreState()
|
||||||
|
@ -241,7 +248,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
activity?.contentResolver?.let {
|
activity?.contentResolver?.let {
|
||||||
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
|
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
|
||||||
}
|
}
|
||||||
val storage = this.storage
|
val storage = this.storageProperties
|
||||||
if (storage?.isUsb == true) {
|
if (storage?.isUsb == true) {
|
||||||
autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" +
|
autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" +
|
||||||
getString(R.string.settings_auto_restore_summary_usb, storage.name)
|
getString(R.string.settings_auto_restore_summary_usb, storage.name)
|
||||||
|
@ -252,7 +259,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private fun setBackupLocationSummary() {
|
private fun setBackupLocationSummary() {
|
||||||
// get name of storage location
|
// get name of storage location
|
||||||
backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none)
|
backupLocation.summary =
|
||||||
|
storageProperties?.name ?: getString(R.string.settings_backup_location_none)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) {
|
private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) {
|
||||||
|
@ -271,7 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
* says that nothing is scheduled which can happen when backup destination is on flash drive.
|
* says that nothing is scheduled which can happen when backup destination is on flash drive.
|
||||||
*/
|
*/
|
||||||
private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) {
|
private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) {
|
||||||
if (storage?.isUsb == true) {
|
if (storageProperties?.isUsb == true) {
|
||||||
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb)
|
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,15 +3,17 @@ package com.stevesoltys.seedvault.settings
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import android.hardware.usb.UsbDevice
|
import android.hardware.usb.UsbDevice
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
import java.util.concurrent.ConcurrentSkipListSet
|
import java.util.concurrent.ConcurrentSkipListSet
|
||||||
|
|
||||||
|
@ -22,6 +24,13 @@ internal const val PREF_KEY_SCHED_FREQ = "scheduling_frequency"
|
||||||
internal const val PREF_KEY_SCHED_METERED = "scheduling_metered"
|
internal const val PREF_KEY_SCHED_METERED = "scheduling_metered"
|
||||||
internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging"
|
internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging"
|
||||||
|
|
||||||
|
private const val PREF_KEY_STORAGE_PLUGIN = "storagePlugin"
|
||||||
|
|
||||||
|
internal enum class StoragePluginType { // don't rename, will break existing installs
|
||||||
|
SAF,
|
||||||
|
WEB_DAV,
|
||||||
|
}
|
||||||
|
|
||||||
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
||||||
private const val PREF_KEY_STORAGE_NAME = "storageName"
|
private const val PREF_KEY_STORAGE_NAME = "storageName"
|
||||||
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
|
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
|
||||||
|
@ -32,6 +41,10 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
|
||||||
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
|
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
|
||||||
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
|
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
|
||||||
|
|
||||||
|
private const val PREF_KEY_WEBDAV_URL = "webDavUrl"
|
||||||
|
private const val PREF_KEY_WEBDAV_USER = "webDavUser"
|
||||||
|
private const val PREF_KEY_WEBDAV_PASS = "webDavPass"
|
||||||
|
|
||||||
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
|
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
|
||||||
|
|
||||||
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
|
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
|
||||||
|
@ -88,24 +101,55 @@ class SettingsManager(private val context: Context) {
|
||||||
token = newToken
|
token = newToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME Storage is currently plugin specific and not generic
|
internal val storagePluginType: StoragePluginType?
|
||||||
fun setStorage(storage: Storage) {
|
get() {
|
||||||
|
val savedType = prefs.getString(PREF_KEY_STORAGE_PLUGIN, null)
|
||||||
|
return if (savedType == null) {
|
||||||
|
// check if this is an existing user that needs to be migrated
|
||||||
|
// this check could be removed after a reasonable migration time (added 2024)
|
||||||
|
if (prefs.getString(PREF_KEY_STORAGE_URI, null) != null) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
.putString(PREF_KEY_STORAGE_PLUGIN, StoragePluginType.SAF.name)
|
||||||
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
.apply()
|
||||||
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
|
StoragePluginType.SAF
|
||||||
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork)
|
} else null
|
||||||
|
} else savedType.let {
|
||||||
|
try {
|
||||||
|
StoragePluginType.valueOf(it)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStoragePlugin(plugin: StoragePlugin<*>) {
|
||||||
|
val value = when (plugin) {
|
||||||
|
is DocumentsProviderStoragePlugin -> StoragePluginType.SAF
|
||||||
|
is WebDavStoragePlugin -> StoragePluginType.WEB_DAV
|
||||||
|
else -> error("Unsupported plugin: ${plugin::class.java.simpleName}")
|
||||||
|
}.name
|
||||||
|
prefs.edit()
|
||||||
|
.putString(PREF_KEY_STORAGE_PLUGIN, value)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStorage(): Storage? {
|
fun setSafStorage(safStorage: SafStorage) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(PREF_KEY_STORAGE_URI, safStorage.uri.toString())
|
||||||
|
.putString(PREF_KEY_STORAGE_NAME, safStorage.name)
|
||||||
|
.putBoolean(PREF_KEY_STORAGE_IS_USB, safStorage.isUsb)
|
||||||
|
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, safStorage.requiresNetwork)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSafStorage(): SafStorage? {
|
||||||
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
||||||
val uri = Uri.parse(uriStr)
|
val uri = Uri.parse(uriStr)
|
||||||
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
|
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
|
||||||
?: throw IllegalStateException("no storage name")
|
?: throw IllegalStateException("no storage name")
|
||||||
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
|
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
|
||||||
val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false)
|
val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false)
|
||||||
return Storage(uri, name, isUsb, requiresNetwork)
|
return SafStorage(uri, name, isUsb, requiresNetwork)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFlashDrive(usb: FlashDrive?) {
|
fun setFlashDrive(usb: FlashDrive?) {
|
||||||
|
@ -134,20 +178,22 @@ class SettingsManager(private val context: Context) {
|
||||||
return FlashDrive(name, serialNumber, vendorId, productId)
|
return FlashDrive(name, serialNumber, vendorId, productId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
val webDavProperties: WebDavProperties?
|
||||||
* Check if we are able to do backups now by examining possible pre-conditions
|
get() {
|
||||||
* such as plugged-in flash drive or internet access.
|
val config = WebDavConfig(
|
||||||
*
|
url = prefs.getString(PREF_KEY_WEBDAV_URL, null) ?: return null,
|
||||||
* Should be run off the UI thread (ideally I/O) because of disk access.
|
username = prefs.getString(PREF_KEY_WEBDAV_USER, null) ?: return null,
|
||||||
*
|
password = prefs.getString(PREF_KEY_WEBDAV_PASS, null) ?: return null,
|
||||||
* @return true if a backup is possible, false if not.
|
)
|
||||||
*/
|
return createWebDavProperties(context, config)
|
||||||
@WorkerThread
|
}
|
||||||
fun canDoBackupNow(): Boolean {
|
|
||||||
val storage = getStorage() ?: return false
|
fun saveWebDavConfig(config: WebDavConfig) {
|
||||||
val systemContext = context.getStorageContext { storage.isUsb }
|
prefs.edit()
|
||||||
return !storage.isUnavailableUsb(systemContext) &&
|
.putString(PREF_KEY_WEBDAV_URL, config.url)
|
||||||
!storage.isUnavailableNetwork(context, useMeteredNetwork)
|
.putString(PREF_KEY_WEBDAV_USER, config.username)
|
||||||
|
.putString(PREF_KEY_WEBDAV_PASS, config.password)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backupApks(): Boolean {
|
fun backupApks(): Boolean {
|
||||||
|
@ -195,42 +241,6 @@ class SettingsManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Storage(
|
|
||||||
val uri: Uri,
|
|
||||||
val name: String,
|
|
||||||
val isUsb: Boolean,
|
|
||||||
val requiresNetwork: Boolean,
|
|
||||||
) {
|
|
||||||
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
|
|
||||||
?: throw AssertionError("Should only happen on API < 21.")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this is USB storage that is not available, false otherwise.
|
|
||||||
*
|
|
||||||
* Must be run off UI thread (ideally I/O).
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
fun isUnavailableUsb(context: Context): Boolean {
|
|
||||||
return isUsb && !getDocumentFile(context).isDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this is storage that requires network access,
|
|
||||||
* but it isn't available right now.
|
|
||||||
*/
|
|
||||||
fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
|
|
||||||
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean {
|
|
||||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
|
||||||
val isMetered = cm.isActiveNetworkMetered
|
|
||||||
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
|
||||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
||||||
(allowMetered || !isMetered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FlashDrive(
|
data class FlashDrive(
|
||||||
val name: String,
|
val name: String,
|
||||||
val serialNumber: String?,
|
val serialNumber: String?,
|
||||||
|
|
|
@ -28,12 +28,14 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.recyclerview.widget.DiffUtil.calculateDiff
|
import androidx.recyclerview.widget.DiffUtil.calculateDiff
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupService
|
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
|
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
|
||||||
|
@ -44,6 +46,9 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
|
@ -59,12 +64,14 @@ internal class SettingsViewModel(
|
||||||
app: Application,
|
app: Application,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
keyManager: KeyManager,
|
keyManager: KeyManager,
|
||||||
|
private val pluginManager: StoragePluginManager,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val appListRetriever: AppListRetriever,
|
private val appListRetriever: AppListRetriever,
|
||||||
private val storageBackup: StorageBackup,
|
private val storageBackup: StorageBackup,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupInitializer: BackupInitializer,
|
private val backupInitializer: BackupInitializer,
|
||||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
backupStateManager: BackupStateManager,
|
||||||
|
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) {
|
||||||
|
|
||||||
private val contentResolver = app.contentResolver
|
private val contentResolver = app.contentResolver
|
||||||
private val connectivityManager: ConnectivityManager? =
|
private val connectivityManager: ConnectivityManager? =
|
||||||
|
@ -73,6 +80,7 @@ internal class SettingsViewModel(
|
||||||
|
|
||||||
override val isRestoreOperation = false
|
override val isRestoreOperation = false
|
||||||
|
|
||||||
|
val isBackupRunning: StateFlow<Boolean>
|
||||||
private val mBackupPossible = MutableLiveData(false)
|
private val mBackupPossible = MutableLiveData(false)
|
||||||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||||
|
|
||||||
|
@ -91,11 +99,11 @@ internal class SettingsViewModel(
|
||||||
private val mAppEditMode = MutableLiveData<Boolean>()
|
private val mAppEditMode = MutableLiveData<Boolean>()
|
||||||
internal val appEditMode: LiveData<Boolean> = mAppEditMode
|
internal val appEditMode: LiveData<Boolean> = mAppEditMode
|
||||||
|
|
||||||
private val _filesSummary = MutableLiveData<String>()
|
private val mFilesSummary = MutableLiveData<String>()
|
||||||
internal val filesSummary: LiveData<String> = _filesSummary
|
internal val filesSummary: LiveData<String> = mFilesSummary
|
||||||
|
|
||||||
private val _initEvent = MutableLiveEvent<Boolean>()
|
private val mInitEvent = MutableLiveEvent<Boolean>()
|
||||||
val initEvent: LiveEvent<Boolean> = _initEvent
|
val initEvent: LiveEvent<Boolean> = mInitEvent
|
||||||
|
|
||||||
private val storageObserver = object : ContentObserver(null) {
|
private val storageObserver = object : ContentObserver(null) {
|
||||||
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
|
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
|
||||||
|
@ -122,18 +130,27 @@ internal class SettingsViewModel(
|
||||||
// this shouldn't cause disk reads, but it still does
|
// this shouldn't cause disk reads, but it still does
|
||||||
viewModelScope
|
viewModelScope
|
||||||
}
|
}
|
||||||
|
isBackupRunning = backupStateManager.isBackupRunning.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// ensures the lastBackupTime LiveData gets set
|
// ensures the lastBackupTime LiveData gets set
|
||||||
metadataManager.getLastBackupTime()
|
metadataManager.getLastBackupTime()
|
||||||
|
// update running state
|
||||||
|
isBackupRunning.collect {
|
||||||
|
onBackupRunningStateChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onStoragePropertiesChanged()
|
onStoragePropertiesChanged()
|
||||||
loadFilesSummary()
|
loadFilesSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageLocationChanged() {
|
override fun onStorageLocationChanged() {
|
||||||
val storage = settingsManager.getStorage() ?: return
|
val storage = pluginManager.storageProperties ?: return
|
||||||
|
|
||||||
Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb}")
|
Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb})")
|
||||||
if (storage.isUsb) {
|
if (storage.isUsb) {
|
||||||
// disable storage backup if new storage is on USB
|
// disable storage backup if new storage is on USB
|
||||||
cancelAppBackup()
|
cancelAppBackup()
|
||||||
|
@ -147,33 +164,37 @@ internal class SettingsViewModel(
|
||||||
onStoragePropertiesChanged()
|
onStoragePropertiesChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onWorkerStateChanged() {
|
private fun onBackupRunningStateChanged() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
if (isBackupRunning.value) mBackupPossible.postValue(false)
|
||||||
val canDo = settingsManager.canDoBackupNow() &&
|
else viewModelScope.launch(Dispatchers.IO) {
|
||||||
appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING
|
val canDo = !isBackupRunning.value && !pluginManager.isOnUnavailableUsb()
|
||||||
mBackupPossible.postValue(canDo)
|
mBackupPossible.postValue(canDo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onStoragePropertiesChanged() {
|
private fun onStoragePropertiesChanged() {
|
||||||
val storage = settingsManager.getStorage() ?: return
|
val storage = pluginManager.storageProperties ?: return
|
||||||
|
|
||||||
Log.d(TAG, "onStoragePropertiesChanged")
|
Log.d(TAG, "onStoragePropertiesChanged")
|
||||||
|
if (storage is SafStorage) {
|
||||||
// register storage observer
|
// register storage observer
|
||||||
try {
|
try {
|
||||||
contentResolver.unregisterContentObserver(storageObserver)
|
contentResolver.unregisterContentObserver(storageObserver)
|
||||||
contentResolver.registerContentObserver(storage.uri, false, storageObserver)
|
contentResolver.registerContentObserver(storage.uri, false, storageObserver)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// This can happen if the app providing the storage was uninstalled.
|
// This can happen if the app providing the storage was uninstalled.
|
||||||
// validLocationIsSet() gets called elsewhere and prompts for a new storage location.
|
// validLocationIsSet() gets called elsewhere
|
||||||
|
// and prompts for a new storage location.
|
||||||
Log.e(TAG, "Error registering content observer for ${storage.uri}", e)
|
Log.e(TAG, "Error registering content observer for ${storage.uri}", e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// register network observer if needed
|
// register network observer if needed
|
||||||
if (networkCallback.registered && !storage.requiresNetwork) {
|
if (networkCallback.registered && !storage.requiresNetwork) {
|
||||||
connectivityManager?.unregisterNetworkCallback(networkCallback)
|
connectivityManager?.unregisterNetworkCallback(networkCallback)
|
||||||
networkCallback.registered = false
|
networkCallback.registered = false
|
||||||
} else if (!networkCallback.registered && storage.requiresNetwork) {
|
} else if (!networkCallback.registered && storage.requiresNetwork) {
|
||||||
|
// TODO we may want to warn the user when they start a backup on a metered connection
|
||||||
val request = NetworkRequest.Builder()
|
val request = NetworkRequest.Builder()
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
.build()
|
.build()
|
||||||
|
@ -181,7 +202,7 @@ internal class SettingsViewModel(
|
||||||
networkCallback.registered = true
|
networkCallback.registered = true
|
||||||
}
|
}
|
||||||
// update whether we can do backups right now or not
|
// update whether we can do backups right now or not
|
||||||
onWorkerStateChanged()
|
onBackupRunningStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
@ -200,8 +221,7 @@ internal class SettingsViewModel(
|
||||||
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||||
startForegroundService(app, i)
|
startForegroundService(app, i)
|
||||||
} else {
|
} else {
|
||||||
val isUsb = settingsManager.getStorage()?.isUsb ?: false
|
AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive)
|
||||||
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -232,7 +252,7 @@ internal class SettingsViewModel(
|
||||||
@UiThread
|
@UiThread
|
||||||
fun loadFilesSummary() = viewModelScope.launch {
|
fun loadFilesSummary() = viewModelScope.launch {
|
||||||
val uriSummary = storageBackup.getUriSummaryString()
|
val uriSummary = storageBackup.getUriSummaryString()
|
||||||
_filesSummary.value = uriSummary.ifEmpty {
|
mFilesSummary.value = uriSummary.ifEmpty {
|
||||||
app.getString(R.string.settings_backup_files_summary)
|
app.getString(R.string.settings_backup_files_summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -248,10 +268,10 @@ internal class SettingsViewModel(
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
backupInitializer.initialize(onError) {
|
backupInitializer.initialize(onError) {
|
||||||
_initEvent.postEvent(false)
|
mInitEvent.postEvent(false)
|
||||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
||||||
}
|
}
|
||||||
_initEvent.postEvent(true)
|
mInitEvent.postEvent(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// enable call log backups for existing installs (added end of 2020)
|
// enable call log backups for existing installs (added end of 2020)
|
||||||
|
@ -280,20 +300,19 @@ internal class SettingsViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
|
fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
|
||||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
if (!pluginManager.isOnRemovableDrive && backupManager.isBackupEnabled) {
|
||||||
if (!storage.isUsb && backupManager.isBackupEnabled) {
|
|
||||||
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
|
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleFilesBackup() {
|
fun scheduleFilesBackup() {
|
||||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
if (!pluginManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) {
|
||||||
if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) {
|
val requiresNetwork = pluginManager.storageProperties?.requiresNetwork == true
|
||||||
BackupJobService.scheduleJob(
|
BackupJobService.scheduleJob(
|
||||||
context = app,
|
context = app,
|
||||||
jobServiceClass = StorageBackupJobService::class.java,
|
jobServiceClass = StorageBackupJobService::class.java,
|
||||||
periodMillis = HOURS.toMillis(24),
|
periodMillis = HOURS.toMillis(24),
|
||||||
networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED
|
networkType = if (requiresNetwork) NETWORK_TYPE_UNMETERED
|
||||||
else NETWORK_TYPE_NONE,
|
else NETWORK_TYPE_NONE,
|
||||||
deviceIdle = false,
|
deviceIdle = false,
|
||||||
charging = true
|
charging = true
|
||||||
|
@ -301,7 +320,7 @@ internal class SettingsViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelAppBackup() {
|
private fun cancelAppBackup() {
|
||||||
AppBackupWorker.unschedule(app)
|
AppBackupWorker.unschedule(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
internal class SeedvaultStoragePlugin(
|
internal class SeedvaultSafStoragePlugin(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
private val storage: DocumentsStorage,
|
||||||
private val keyManager: KeyManager,
|
private val keyManager: KeyManager,
|
||||||
|
@ -16,12 +16,8 @@ internal class SeedvaultStoragePlugin(
|
||||||
/**
|
/**
|
||||||
* Attention: This context might be from a different user. Use with care.
|
* Attention: This context might be from a different user. Use with care.
|
||||||
*/
|
*/
|
||||||
override val context: Context
|
override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
||||||
get() = appContext.getStorageContext {
|
override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
|
||||||
storage.storage?.isUsb == true
|
|
||||||
}
|
|
||||||
override val root: DocumentFile
|
|
||||||
get() = storage.rootBackupDir ?: error("No storage set")
|
|
||||||
|
|
||||||
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
|
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
|
||||||
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
|
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
|
|
@ -1,8 +1,10 @@
|
||||||
package com.stevesoltys.seedvault.storage
|
package com.stevesoltys.seedvault.storage
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.calyxos.backup.storage.api.BackupObserver
|
import org.calyxos.backup.storage.api.BackupObserver
|
||||||
import org.calyxos.backup.storage.api.RestoreObserver
|
import org.calyxos.backup.storage.api.RestoreObserver
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
|
@ -31,19 +33,31 @@ internal class StorageBackupService : BackupService() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal const val EXTRA_START_APP_BACKUP = "startAppBackup"
|
internal const val EXTRA_START_APP_BACKUP = "startAppBackup"
|
||||||
|
private val mIsRunning = MutableStateFlow(false)
|
||||||
|
val isRunning = mIsRunning.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val storageBackup: StorageBackup by inject()
|
override val storageBackup: StorageBackup by inject()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val storagePluginManager: StoragePluginManager by inject()
|
||||||
|
|
||||||
// use lazy delegate because context isn't available during construction time
|
// use lazy delegate because context isn't available during construction time
|
||||||
override val backupObserver: BackupObserver by lazy {
|
override val backupObserver: BackupObserver by lazy {
|
||||||
NotificationBackupObserver(applicationContext)
|
NotificationBackupObserver(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
mIsRunning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
mIsRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackupFinished(intent: Intent, success: Boolean) {
|
override fun onBackupFinished(intent: Intent, success: Boolean) {
|
||||||
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
|
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
|
||||||
val isUsb = settingsManager.getStorage()?.isUsb ?: false
|
val isUsb = storagePluginManager.storageProperties?.isUsb ?: false
|
||||||
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
|
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
package com.stevesoltys.seedvault.storage
|
package com.stevesoltys.seedvault.storage
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
import org.calyxos.backup.storage.api.StoragePlugin
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val storageModule = module {
|
val storageModule = module {
|
||||||
single<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) }
|
single { StorageBackup(get(), { get<StoragePluginManager>().filesPlugin }) }
|
||||||
single { StorageBackup(get(), get()) }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 android.util.Log
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
@ -18,6 +20,11 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
||||||
*/
|
*/
|
||||||
class ConfigurableBackupTransportService : Service(), KoinComponent {
|
class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val mIsRunning = MutableStateFlow(false)
|
||||||
|
val isRunning = mIsRunning.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
private var transport: ConfigurableBackupTransport? = null
|
private var transport: ConfigurableBackupTransport? = null
|
||||||
|
|
||||||
private val keyManager: KeyManager by inject()
|
private val keyManager: KeyManager by inject()
|
||||||
|
@ -27,6 +34,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
transport = ConfigurableBackupTransport(applicationContext)
|
transport = ConfigurableBackupTransport(applicationContext)
|
||||||
|
mIsRunning.value = true
|
||||||
Log.d(TAG, "Service created.")
|
Log.d(TAG, "Service created.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +55,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
notificationManager.onServiceDestroyed()
|
notificationManager.onServiceDestroyed()
|
||||||
transport = null
|
transport = null
|
||||||
|
mIsRunning.value = false
|
||||||
Log.d(TAG, "Service destroyed.")
|
Log.d(TAG, "Service destroyed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
@ -54,11 +55,10 @@ private class CoordinatorState(
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
|
@WorkerThread
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class BackupCoordinator(
|
internal class BackupCoordinator(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val kv: KVBackup,
|
private val kv: KVBackup,
|
||||||
private val full: FullBackup,
|
private val full: FullBackup,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
|
@ -68,6 +68,7 @@ internal class BackupCoordinator(
|
||||||
private val nm: BackupNotificationManager,
|
private val nm: BackupNotificationManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin get() = pluginManager.appPlugin
|
||||||
private val state = CoordinatorState(
|
private val state = CoordinatorState(
|
||||||
calledInitialize = false,
|
calledInitialize = false,
|
||||||
calledClearBackupData = false,
|
calledClearBackupData = false,
|
||||||
|
@ -126,7 +127,7 @@ internal class BackupCoordinator(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error initializing device", e)
|
Log.e(TAG, "Error initializing device", e)
|
||||||
// Show error notification if we needed init or were ready for backups
|
// Show error notification if we needed init or were ready for backups
|
||||||
if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError()
|
if (metadataManager.requiresInit || pluginManager.canDoBackupNow()) nm.onBackupError()
|
||||||
TRANSPORT_ERROR
|
TRANSPORT_ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,7 +355,7 @@ internal class BackupCoordinator(
|
||||||
if (result == TRANSPORT_OK) {
|
if (result == TRANSPORT_OK) {
|
||||||
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
||||||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
||||||
if (isNormalBackup || settingsManager.canDoBackupNow()) {
|
if (isNormalBackup || pluginManager.canDoBackupNow()) {
|
||||||
try {
|
try {
|
||||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -411,7 +412,7 @@ internal class BackupCoordinator(
|
||||||
val longBackoff = DAYS.toMillis(30)
|
val longBackoff = DAYS.toMillis(30)
|
||||||
|
|
||||||
// back off if there's no storage set
|
// back off if there's no storage set
|
||||||
val storage = settingsManager.getStorage() ?: return longBackoff
|
val storage = pluginManager.storageProperties ?: return longBackoff
|
||||||
return when {
|
return when {
|
||||||
// back off if storage is removable and not available right now
|
// back off if storage is removable and not available right now
|
||||||
storage.isUnavailableUsb(context) -> longBackoff
|
storage.isUnavailableUsb(context) -> longBackoff
|
||||||
|
@ -425,7 +426,9 @@ internal class BackupCoordinator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream {
|
private suspend fun StoragePlugin<*>.getMetadataOutputStream(
|
||||||
|
token: Long? = null,
|
||||||
|
): OutputStream {
|
||||||
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
|
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
|
||||||
return getOutputStream(t, FILE_BACKUP_METADATA)
|
return getOutputStream(t, FILE_BACKUP_METADATA)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,38 +11,38 @@ val backupModule = module {
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
backupManager = get(),
|
backupManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
plugin = get()
|
pluginManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
||||||
single {
|
single {
|
||||||
KVBackup(
|
KVBackup(
|
||||||
plugin = get(),
|
pluginManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
inputFactory = get(),
|
inputFactory = get(),
|
||||||
crypto = get(),
|
crypto = get(),
|
||||||
dbManager = get()
|
dbManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
FullBackup(
|
FullBackup(
|
||||||
plugin = get(),
|
pluginManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
inputFactory = get(),
|
inputFactory = get(),
|
||||||
crypto = get()
|
crypto = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
BackupCoordinator(
|
BackupCoordinator(
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
plugin = get(),
|
pluginManager = get(),
|
||||||
kv = get(),
|
kv = get(),
|
||||||
full = get(),
|
full = get(),
|
||||||
clock = get(),
|
clock = get(),
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
metadataManager = get(),
|
metadataManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
nm = get()
|
nm = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import android.util.Log
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
import com.stevesoltys.seedvault.header.getADForFull
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
|
@ -39,12 +39,13 @@ private val TAG = FullBackup::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class FullBackup(
|
internal class FullBackup(
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin get() = pluginManager.appPlugin
|
||||||
private var state: FullBackupState? = null
|
private var state: FullBackupState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
fun hasState() = state != null
|
||||||
|
|
|
@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
import com.stevesoltys.seedvault.header.getADForKV
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
|
@ -31,15 +31,15 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
|
||||||
|
|
||||||
private val TAG = KVBackup::class.java.simpleName
|
private val TAG = KVBackup::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class KVBackup(
|
internal class KVBackup(
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
private val dbManager: KvDbManager,
|
private val dbManager: KvDbManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin get() = pluginManager.appPlugin
|
||||||
private var state: KVBackupState? = null
|
private var state: KVBackupState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
fun hasState() = state != null
|
||||||
|
@ -138,7 +138,7 @@ internal class KVBackup(
|
||||||
// K/V backups (typically starting with package manager metadata - @pm@)
|
// K/V backups (typically starting with package manager metadata - @pm@)
|
||||||
// are scheduled with JobInfo.Builder#setOverrideDeadline()
|
// are scheduled with JobInfo.Builder#setOverrideDeadline()
|
||||||
// and thus do not respect backoff.
|
// and thus do not respect backoff.
|
||||||
settingsManager.canDoBackupNow()
|
pluginManager.canDoBackupNow()
|
||||||
} else {
|
} else {
|
||||||
// all other packages always need upload
|
// all other packages always need upload
|
||||||
true
|
true
|
||||||
|
|
|
@ -18,6 +18,7 @@ import android.util.Log.INFO
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
|
||||||
private val TAG = PackageService::class.java.simpleName
|
private val TAG = PackageService::class.java.simpleName
|
||||||
|
@ -32,11 +33,12 @@ internal class PackageService(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val packageManager: PackageManager = context.packageManager
|
private val packageManager: PackageManager = context.packageManager
|
||||||
private val myUserId = UserHandle.myUserId()
|
private val myUserId = UserHandle.myUserId()
|
||||||
|
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
|
||||||
|
|
||||||
val eligiblePackages: List<String>
|
val eligiblePackages: List<String>
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
|
|
|
@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
import com.stevesoltys.seedvault.header.getADForFull
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -32,9 +32,8 @@ private class FullRestoreState(
|
||||||
|
|
||||||
private val TAG = FullRestore::class.java.simpleName
|
private val TAG = FullRestore::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class FullRestore(
|
internal class FullRestore(
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin: LegacyStoragePlugin,
|
private val legacyPlugin: LegacyStoragePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -42,6 +41,7 @@ internal class FullRestore(
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin get() = pluginManager.appPlugin
|
||||||
private var state: FullRestoreState? = null
|
private var state: FullRestoreState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
fun hasState() = state != null
|
||||||
|
|
|
@ -16,13 +16,12 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
import com.stevesoltys.seedvault.header.getADForKV
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.KVDb
|
import com.stevesoltys.seedvault.transport.backup.KVDb
|
||||||
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.security.GeneralSecurityException
|
import java.security.GeneralSecurityException
|
||||||
import java.util.ArrayList
|
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import javax.crypto.AEADBadTagException
|
import javax.crypto.AEADBadTagException
|
||||||
|
|
||||||
|
@ -39,9 +38,8 @@ private class KVRestoreState(
|
||||||
|
|
||||||
private val TAG = KVRestore::class.java.simpleName
|
private val TAG = KVRestore::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class KVRestore(
|
internal class KVRestore(
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin: LegacyStoragePlugin,
|
private val legacyPlugin: LegacyStoragePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -50,6 +48,7 @@ internal class KVRestore(
|
||||||
private val dbManager: KvDbManager,
|
private val dbManager: KvDbManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin get() = pluginManager.appPlugin
|
||||||
private var state: KVRestoreState? = null
|
private var state: KVRestoreState? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
||||||
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
||||||
|
@ -49,19 +50,19 @@ private data class RestoreCoordinatorState(
|
||||||
|
|
||||||
private val TAG = RestoreCoordinator::class.java.simpleName
|
private val TAG = RestoreCoordinator::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class RestoreCoordinator(
|
internal class RestoreCoordinator(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val notificationManager: BackupNotificationManager,
|
private val notificationManager: BackupNotificationManager,
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val kv: KVRestore,
|
private val kv: KVRestore,
|
||||||
private val full: FullRestore,
|
private val full: FullRestore,
|
||||||
private val metadataReader: MetadataReader,
|
private val metadataReader: MetadataReader,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
|
||||||
private var state: RestoreCoordinatorState? = null
|
private var state: RestoreCoordinatorState? = null
|
||||||
private var backupMetadata: BackupMetadata? = null
|
private var backupMetadata: BackupMetadata? = null
|
||||||
private val failedPackages = ArrayList<String>()
|
private val failedPackages = ArrayList<String>()
|
||||||
|
@ -169,7 +170,7 @@ internal class RestoreCoordinator(
|
||||||
// check if we even have a backup of that app
|
// check if we even have a backup of that app
|
||||||
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
|
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
|
||||||
// remind user to plug in storage device
|
// remind user to plug in storage device
|
||||||
val storageName = settingsManager.getStorage()?.name
|
val storageName = pluginManager.storageProperties?.name
|
||||||
?: context.getString(R.string.settings_backup_location_none)
|
?: context.getString(R.string.settings_backup_location_none)
|
||||||
notificationManager.onRemovableStorageNotAvailableForRestore(
|
notificationManager.onRemovableStorageNotAvailableForRestore(
|
||||||
pmPackageName,
|
pmPackageName,
|
||||||
|
@ -363,9 +364,8 @@ internal class RestoreCoordinator(
|
||||||
|
|
||||||
fun isFailedPackage(packageName: String) = packageName in failedPackages
|
fun isFailedPackage(packageName: String) = packageName in failedPackages
|
||||||
|
|
||||||
// TODO this is plugin specific, needs to be factored out when supporting different plugins
|
|
||||||
private fun isStorageRemovableAndNotAvailable(): Boolean {
|
private fun isStorageRemovableAndNotAvailable(): Boolean {
|
||||||
val storage = settingsManager.getStorage() ?: return false
|
val storage = pluginManager.storageProperties ?: return false
|
||||||
return storage.isUnavailableUsb(context)
|
return storage.isUnavailableUsb(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,14 @@ package com.stevesoltys.seedvault.ui
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageViewModel
|
|
||||||
|
|
||||||
abstract class RequireProvisioningViewModel(
|
abstract class RequireProvisioningViewModel(
|
||||||
protected val app: Application,
|
protected val app: Application,
|
||||||
protected val settingsManager: SettingsManager,
|
protected val settingsManager: SettingsManager,
|
||||||
protected val keyManager: KeyManager,
|
protected val keyManager: KeyManager,
|
||||||
|
private val pluginManager: StoragePluginManager,
|
||||||
) : AndroidViewModel(app) {
|
) : AndroidViewModel(app) {
|
||||||
|
|
||||||
abstract val isRestoreOperation: Boolean
|
abstract val isRestoreOperation: Boolean
|
||||||
|
@ -18,7 +19,7 @@ abstract class RequireProvisioningViewModel(
|
||||||
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
|
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
|
||||||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||||
|
|
||||||
internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app, settingsManager)
|
internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet()
|
||||||
|
|
||||||
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
|
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
|
||||||
|
|
||||||
|
|
|
@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel(
|
||||||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
// remove old storage snapshots and clear cache
|
// remove old storage snapshots and clear cache
|
||||||
storageBackup.deleteAllSnapshots()
|
storageBackup.init()
|
||||||
storageBackup.clearCache()
|
|
||||||
try {
|
try {
|
||||||
// initialize the new location
|
// initialize the new location
|
||||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
||||||
|
|
|
@ -3,11 +3,16 @@ package com.stevesoltys.seedvault.ui.storage
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.backup.IBackupManager
|
import android.app.backup.IBackupManager
|
||||||
import android.app.job.JobInfo
|
import android.app.job.JobInfo
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafHandler
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||||
|
@ -26,14 +31,18 @@ internal class BackupStorageViewModel(
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupInitializer: BackupInitializer,
|
private val backupInitializer: BackupInitializer,
|
||||||
private val storageBackup: StorageBackup,
|
private val storageBackup: StorageBackup,
|
||||||
|
safHandler: SafHandler,
|
||||||
|
webDavHandler: WebDavHandler,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
) : StorageViewModel(app, settingsManager) {
|
storagePluginManager: StoragePluginManager,
|
||||||
|
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
|
||||||
|
|
||||||
override val isRestoreOperation = false
|
override val isRestoreOperation = false
|
||||||
|
|
||||||
override fun onLocationSet(uri: Uri) {
|
override fun onSafUriSet(safStorage: SafStorage) {
|
||||||
val isUsb = saveStorage(uri)
|
safHandler.save(safStorage)
|
||||||
if (isUsb) {
|
safHandler.setPlugin(safStorage)
|
||||||
|
if (safStorage.isUsb) {
|
||||||
// disable storage backup if new storage is on USB
|
// disable storage backup if new storage is on USB
|
||||||
cancelBackupWorkers()
|
cancelBackupWorkers()
|
||||||
} else {
|
} else {
|
||||||
|
@ -41,13 +50,23 @@ internal class BackupStorageViewModel(
|
||||||
// also to update the network requirement of the new storage
|
// also to update the network requirement of the new storage
|
||||||
scheduleBackupWorkers()
|
scheduleBackupWorkers()
|
||||||
}
|
}
|
||||||
|
onStorageLocationSet(safStorage.isUsb)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
|
||||||
|
webdavHandler.save(properties)
|
||||||
|
webdavHandler.setPlugin(properties, plugin)
|
||||||
|
scheduleBackupWorkers()
|
||||||
|
onStorageLocationSet(isUsb = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStorageLocationSet(isUsb: Boolean) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
// remove old storage snapshots and clear cache
|
|
||||||
// TODO is this needed? It also does create all 255 chunk folders which takes time
|
|
||||||
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
|
|
||||||
storageBackup.deleteAllSnapshots()
|
|
||||||
storageBackup.clearCache()
|
|
||||||
try {
|
try {
|
||||||
|
// remove old storage snapshots and clear cache
|
||||||
|
// TODO For SAF, this also does create all 255 chunk folders which takes time
|
||||||
|
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
|
||||||
|
storageBackup.init()
|
||||||
// initialize the new location (if backups are enabled)
|
// initialize the new location (if backups are enabled)
|
||||||
if (backupManager.isBackupEnabled) {
|
if (backupManager.isBackupEnabled) {
|
||||||
val onError = {
|
val onError = {
|
||||||
|
@ -75,7 +94,7 @@ internal class BackupStorageViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleBackupWorkers() {
|
private fun scheduleBackupWorkers() {
|
||||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
val storage = storagePluginManager.storageProperties ?: error("no storage available")
|
||||||
if (!storage.isUsb) {
|
if (!storage.isUsb) {
|
||||||
if (backupManager.isBackupEnabled) {
|
if (backupManager.isBackupEnabled) {
|
||||||
AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE)
|
AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE)
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package com.stevesoltys.seedvault.ui.storage
|
package com.stevesoltys.seedvault.ui.storage
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
|
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafHandler
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -16,26 +20,28 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName
|
||||||
|
|
||||||
internal class RestoreStorageViewModel(
|
internal class RestoreStorageViewModel(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val storagePlugin: StoragePlugin,
|
safHandler: SafHandler,
|
||||||
|
webDavHandler: WebDavHandler,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
) : StorageViewModel(app, settingsManager) {
|
storagePluginManager: StoragePluginManager,
|
||||||
|
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
|
||||||
|
|
||||||
override val isRestoreOperation = true
|
override val isRestoreOperation = true
|
||||||
|
|
||||||
override fun onLocationSet(uri: Uri) {
|
override fun onSafUriSet(safStorage: SafStorage) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val storage = createStorage(uri)
|
|
||||||
val hasBackup = try {
|
val hasBackup = try {
|
||||||
storagePlugin.hasBackup(storage)
|
safHandler.hasAppBackup(safStorage)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error reading URI: $uri", e)
|
Log.e(TAG, "Error reading URI: ${safStorage.uri}", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (hasBackup) {
|
if (hasBackup) {
|
||||||
saveStorage(storage)
|
safHandler.save(safStorage)
|
||||||
|
safHandler.setPlugin(safStorage)
|
||||||
mLocationChecked.postEvent(LocationResult())
|
mLocationChecked.postEvent(LocationResult())
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Location was rejected: $uri")
|
Log.w(TAG, "Location was rejected: ${safStorage.uri}")
|
||||||
|
|
||||||
// notify the UI that the location was invalid
|
// notify the UI that the location was invalid
|
||||||
val errorMsg =
|
val errorMsg =
|
||||||
|
@ -45,4 +51,26 @@ internal class RestoreStorageViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val hasBackup = try {
|
||||||
|
webdavHandler.hasAppBackup(plugin)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error reading: ${properties.config.url}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (hasBackup) {
|
||||||
|
webdavHandler.save(properties)
|
||||||
|
webdavHandler.setPlugin(properties, plugin)
|
||||||
|
mLocationChecked.postEvent(LocationResult())
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Location was rejected: ${properties.config.url}")
|
||||||
|
|
||||||
|
// notify the UI that the location was invalid
|
||||||
|
val errorMsg =
|
||||||
|
app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
|
||||||
|
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,11 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver
|
||||||
import com.stevesoltys.seedvault.ui.BackupActivity
|
import com.stevesoltys.seedvault.ui.BackupActivity
|
||||||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
||||||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
|
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
|
||||||
|
@ -82,14 +82,6 @@ class StorageActivity : BackupActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
|
||||||
Log.d(TAG, "Blocking back button.")
|
|
||||||
} else {
|
|
||||||
super.onBackPressed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onInvalidLocation(errorMsg: String) {
|
private fun onInvalidLocation(errorMsg: String) {
|
||||||
if (viewModel.isRestoreOperation) {
|
if (viewModel.isRestoreOperation) {
|
||||||
val dialog = AlertDialog.Builder(this)
|
val dialog = AlertDialog.Builder(this)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.stevesoltys.seedvault.ui.storage
|
package com.stevesoltys.seedvault.ui.storage
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.GONE
|
import android.view.View.GONE
|
||||||
|
@ -10,6 +11,7 @@ import android.view.ViewGroup
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.addCallback
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
|
||||||
|
@ -34,6 +36,14 @@ class StorageCheckFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(this) {
|
||||||
|
Log.i("StorageCheckFragment", "Not navigating back!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package com.stevesoltys.seedvault.ui.storage
|
package com.stevesoltys.seedvault.ui.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.DocumentsContract.buildTreeDocumentUri
|
import android.provider.DocumentsContract.buildTreeDocumentUri
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||||
|
import com.stevesoltys.seedvault.R
|
||||||
|
|
||||||
internal sealed class StorageOption {
|
internal sealed class StorageOption {
|
||||||
abstract val id: String
|
abstract val id: String
|
||||||
|
@ -46,3 +49,14 @@ internal sealed class StorageOption {
|
||||||
return id.hashCode()
|
return id.hashCode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class WebDavOption(context: Context) : StorageOption() {
|
||||||
|
override val id: String = "webdav"
|
||||||
|
override val icon: Drawable? = getDrawable(context, R.drawable.ic_cloud_circle)
|
||||||
|
override val title: String = context.getString(R.string.storage_webdav_option_title)
|
||||||
|
override val summary: String = context.getString(R.string.storage_webdav_option_summary)
|
||||||
|
override val availableBytes: Long? = null
|
||||||
|
override val requiresNetwork: Boolean = true
|
||||||
|
override val enabled: Boolean = true
|
||||||
|
override val nonDefaultAction: (() -> Unit)? = null
|
||||||
|
}
|
||||||
|
|
|
@ -13,9 +13,11 @@ import android.provider.DocumentsContract.PROVIDER_INTERFACE
|
||||||
import android.provider.DocumentsContract.buildRootsUri
|
import android.provider.DocumentsContract.buildRootsUri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorageOptions
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||||
|
|
||||||
private val TAG = StorageRootFetcher::class.java.simpleName
|
private val TAG = StorageOptionFetcher::class.java.simpleName
|
||||||
|
|
||||||
const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
|
const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
|
||||||
const val ROOT_ID_DEVICE = "primary"
|
const val ROOT_ID_DEVICE = "primary"
|
||||||
|
@ -30,7 +32,7 @@ internal interface RemovableStorageListener {
|
||||||
fun onStorageChanged()
|
fun onStorageChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class StorageRootFetcher(private val context: Context, private val isRestore: Boolean) {
|
internal class StorageOptionFetcher(private val context: Context, private val isRestore: Boolean) {
|
||||||
|
|
||||||
private val packageManager = context.packageManager
|
private val packageManager = context.packageManager
|
||||||
private val contentResolver = context.contentResolver
|
private val contentResolver = context.contentResolver
|
||||||
|
@ -60,7 +62,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
||||||
internal fun getRemovableStorageListener() = listener
|
internal fun getRemovableStorageListener() = listener
|
||||||
|
|
||||||
internal fun getStorageOptions(): List<StorageOption> {
|
internal fun getStorageOptions(): List<StorageOption> {
|
||||||
val roots = ArrayList<SafOption>()
|
val roots = ArrayList<StorageOption>().apply {
|
||||||
|
add(WebDavOption(context))
|
||||||
|
}
|
||||||
val intent = Intent(PROVIDER_INTERFACE)
|
val intent = Intent(PROVIDER_INTERFACE)
|
||||||
val providers = packageManager.queryIntentContentProviders(intent, 0)
|
val providers = packageManager.queryIntentContentProviders(intent, 0)
|
||||||
for (info in providers) {
|
for (info in providers) {
|
|
@ -55,7 +55,7 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?,
|
||||||
): View {
|
): View {
|
||||||
val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false)
|
||||||
|
|
||||||
titleView = v.requireViewById(R.id.titleView)
|
titleView = v.requireViewById(R.id.titleView)
|
||||||
warningIcon = v.requireViewById(R.id.warningIcon)
|
warningIcon = v.requireViewById(R.id.warningIcon)
|
||||||
|
@ -115,11 +115,21 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(storageOption: StorageOption) {
|
override fun onClick(storageOption: StorageOption) {
|
||||||
if (storageOption is SafOption) {
|
when (storageOption) {
|
||||||
|
is SafOption -> {
|
||||||
viewModel.onSafOptionChosen(storageOption)
|
viewModel.onSafOptionChosen(storageOption)
|
||||||
openDocumentTree.launch(storageOption.uri)
|
openDocumentTree.launch(storageOption.uri)
|
||||||
} else {
|
}
|
||||||
throw IllegalArgumentException("Non-SAF storage not yet supported")
|
|
||||||
|
is WebDavOption -> {
|
||||||
|
val isRestore = requireArguments().getBoolean(INTENT_EXTRA_IS_RESTORE)
|
||||||
|
val f = WebDavConfigFragment.newInstance(isRestore)
|
||||||
|
parentFragmentManager.beginTransaction().apply {
|
||||||
|
replace(R.id.fragment, f)
|
||||||
|
addToBackStack("WebDAV")
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,32 +1,33 @@
|
||||||
package com.stevesoltys.seedvault.ui.storage
|
package com.stevesoltys.seedvault.ui.storage
|
||||||
|
|
||||||
|
import android.annotation.UiThread
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.USB_SERVICE
|
|
||||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.isMassStorage
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.plugins.saf.SafHandler
|
||||||
import com.stevesoltys.seedvault.settings.BackupManagerSettings
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
private val TAG = StorageViewModel::class.java.simpleName
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
internal abstract class StorageViewModel(
|
internal abstract class StorageViewModel(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
|
protected val safHandler: SafHandler,
|
||||||
|
protected val webdavHandler: WebDavHandler,
|
||||||
protected val settingsManager: SettingsManager,
|
protected val settingsManager: SettingsManager,
|
||||||
|
protected val storagePluginManager: StoragePluginManager,
|
||||||
) : AndroidViewModel(app), RemovableStorageListener {
|
) : AndroidViewModel(app), RemovableStorageListener {
|
||||||
|
|
||||||
private val mStorageOptions = MutableLiveData<List<StorageOption>>()
|
private val mStorageOptions = MutableLiveData<List<StorageOption>>()
|
||||||
|
@ -38,38 +39,29 @@ internal abstract class StorageViewModel(
|
||||||
protected val mLocationChecked = MutableLiveEvent<LocationResult>()
|
protected val mLocationChecked = MutableLiveEvent<LocationResult>()
|
||||||
internal val locationChecked: LiveEvent<LocationResult> get() = mLocationChecked
|
internal val locationChecked: LiveEvent<LocationResult> get() = mLocationChecked
|
||||||
|
|
||||||
private val storageRootFetcher by lazy { StorageRootFetcher(app, isRestoreOperation) }
|
private val storageOptionFetcher by lazy { StorageOptionFetcher(app, isRestoreOperation) }
|
||||||
private var safOption: SafOption? = null
|
private var safOption: SafOption? = null
|
||||||
|
|
||||||
internal var isSetupWizard: Boolean = false
|
internal var isSetupWizard: Boolean = false
|
||||||
internal val hasStorageSet: Boolean
|
internal val hasStorageSet: Boolean
|
||||||
get() = settingsManager.getStorage() != null
|
get() = storagePluginManager.storageProperties != null
|
||||||
abstract val isRestoreOperation: Boolean
|
abstract val isRestoreOperation: Boolean
|
||||||
|
|
||||||
companion object {
|
|
||||||
internal fun validLocationIsSet(
|
|
||||||
context: Context,
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
): Boolean {
|
|
||||||
val storage = settingsManager.getStorage() ?: return false
|
|
||||||
if (storage.isUsb) return true
|
|
||||||
return permitDiskReads {
|
|
||||||
storage.getDocumentFile(context).isDirectory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun loadStorageRoots() {
|
internal fun loadStorageRoots() {
|
||||||
if (storageRootFetcher.getRemovableStorageListener() == null) {
|
if (storageOptionFetcher.getRemovableStorageListener() == null) {
|
||||||
storageRootFetcher.setRemovableStorageListener(this)
|
storageOptionFetcher.setRemovableStorageListener(this)
|
||||||
}
|
}
|
||||||
Thread {
|
Thread {
|
||||||
mStorageOptions.postValue(storageRootFetcher.getStorageOptions())
|
mStorageOptions.postValue(storageOptionFetcher.getStorageOptions())
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageChanged() = loadStorageRoots()
|
override fun onStorageChanged() = loadStorageRoots()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers that the user chose SAF.
|
||||||
|
* Usually followed by a call of [onUriPermissionResultReceived].
|
||||||
|
*/
|
||||||
fun onSafOptionChosen(option: SafOption) {
|
fun onSafOptionChosen(option: SafOption) {
|
||||||
safOption = option
|
safOption = option
|
||||||
}
|
}
|
||||||
|
@ -80,76 +72,40 @@ internal abstract class StorageViewModel(
|
||||||
mLocationChecked.setEvent(LocationResult(msg))
|
mLocationChecked.setEvent(LocationResult(msg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
require(safOption?.uri == uri) { "URIs differ: ${safOption?.uri} != $uri" }
|
||||||
|
|
||||||
|
val root = safOption ?: throw IllegalStateException("no storage root")
|
||||||
|
val safStorage = safHandler.onConfigReceived(uri, root)
|
||||||
|
|
||||||
// inform UI that a location has been successfully selected
|
// inform UI that a location has been successfully selected
|
||||||
mLocationSet.setEvent(true)
|
mLocationSet.setEvent(true)
|
||||||
|
|
||||||
// persist permission to access backup folder across reboots
|
onSafUriSet(safStorage)
|
||||||
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
app.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
|
||||||
|
|
||||||
onLocationSet(uri)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
abstract fun onSafUriSet(safStorage: SafStorage)
|
||||||
* Saves the storage behind the given [Uri] (and saved [safOption]).
|
abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin)
|
||||||
*
|
|
||||||
* @return true if the storage is a USB flash drive, false otherwise.
|
|
||||||
*/
|
|
||||||
protected fun saveStorage(uri: Uri): Boolean {
|
|
||||||
// store backup storage location in settings
|
|
||||||
val storage = createStorage(uri)
|
|
||||||
return saveStorage(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun createStorage(uri: Uri): Storage {
|
|
||||||
val root = safOption ?: throw IllegalStateException("no storage root")
|
|
||||||
val name = if (root.isInternal()) {
|
|
||||||
"${root.title} (${app.getString(R.string.settings_backup_location_internal)})"
|
|
||||||
} else {
|
|
||||||
root.title
|
|
||||||
}
|
|
||||||
return Storage(uri, name, root.isUsb, root.requiresNetwork)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun saveStorage(storage: Storage): Boolean {
|
|
||||||
settingsManager.setStorage(storage)
|
|
||||||
|
|
||||||
if (storage.isUsb) {
|
|
||||||
Log.d(TAG, "Selected storage is a removable USB device.")
|
|
||||||
val wasSaved = saveUsbDevice()
|
|
||||||
// reset stored flash drive, if we did not update it
|
|
||||||
if (!wasSaved) settingsManager.setFlashDrive(null)
|
|
||||||
} else {
|
|
||||||
settingsManager.setFlashDrive(null)
|
|
||||||
}
|
|
||||||
BackupManagerSettings.resetDefaults(app.contentResolver)
|
|
||||||
|
|
||||||
Log.d(TAG, "New storage location saved: ${storage.uri}")
|
|
||||||
|
|
||||||
return storage.isUsb
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveUsbDevice(): Boolean {
|
|
||||||
val manager = app.getSystemService(USB_SERVICE) as UsbManager
|
|
||||||
manager.deviceList.values.forEach { device ->
|
|
||||||
if (device.isMassStorage()) {
|
|
||||||
val flashDrive = FlashDrive.from(device)
|
|
||||||
settingsManager.setFlashDrive(flashDrive)
|
|
||||||
Log.d(TAG, "Saved flash drive: $flashDrive")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.e(TAG, "No USB device found even though we were expecting one.")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun onLocationSet(uri: Uri)
|
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
storageRootFetcher.setRemovableStorageListener(null)
|
storageOptionFetcher.setRemovableStorageListener(null)
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
val webdavConfigState get() = webdavHandler.configState
|
||||||
|
|
||||||
|
fun onWebDavConfigReceived(url: String, user: String, pass: String) {
|
||||||
|
val config = WebDavConfig(url = url, username = user, password = pass)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
webdavHandler.onConfigReceived(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetWebDavConfig() = webdavHandler.resetConfigState()
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
fun onWebDavConfigSuccess(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
|
||||||
|
mLocationSet.setEvent(true)
|
||||||
|
onWebDavConfigSet(properties, plugin)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
private val TAG = ApkBackup::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class ApkBackup(
|
internal class ApkBackup(
|
||||||
private val pm: PackageManager,
|
private val pm: PackageManager,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
|
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
@ -28,7 +29,7 @@ internal class ApkBackupManager(
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val packageService: PackageService,
|
private val packageService: PackageService,
|
||||||
private val apkBackup: ApkBackup,
|
private val apkBackup: ApkBackup,
|
||||||
private val plugin: StoragePlugin,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val nm: BackupNotificationManager,
|
private val nm: BackupNotificationManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -50,7 +51,7 @@ internal class ApkBackupManager(
|
||||||
keepTrying {
|
keepTrying {
|
||||||
// upload all local changes only at the end,
|
// upload all local changes only at the end,
|
||||||
// so we don't have to re-upload the metadata
|
// so we don't have to re-upload the metadata
|
||||||
plugin.getMetadataOutputStream().use { outputStream ->
|
pluginManager.appPlugin.getMetadataOutputStream().use { outputStream ->
|
||||||
metadataManager.uploadMetadata(outputStream)
|
metadataManager.uploadMetadata(outputStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,7 +103,7 @@ internal class ApkBackupManager(
|
||||||
return try {
|
return try {
|
||||||
apkBackup.backupApkIfNecessary(packageInfo) { name ->
|
apkBackup.backupApkIfNecessary(packageInfo) { name ->
|
||||||
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
||||||
plugin.getOutputStream(token, name)
|
pluginManager.appPlugin.getOutputStream(token, name)
|
||||||
}?.let { packageMetadata ->
|
}?.let { packageMetadata ->
|
||||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
||||||
true
|
true
|
||||||
|
@ -125,7 +126,9 @@ internal class ApkBackupManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream {
|
private suspend fun StoragePlugin<*>.getMetadataOutputStream(
|
||||||
|
token: Long? = null,
|
||||||
|
): OutputStream {
|
||||||
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
|
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
|
||||||
return getOutputStream(t, FILE_BACKUP_METADATA)
|
return getOutputStream(t, FILE_BACKUP_METADATA)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ val workerModule = module {
|
||||||
metadataManager = get(),
|
metadataManager = get(),
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
apkBackup = get(),
|
apkBackup = get(),
|
||||||
plugin = get(),
|
pluginManager = get(),
|
||||||
nm = get()
|
nm = get()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
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_permission_error">Unable to get the permission to write to the backup location.</string>
|
||||||
<string name="storage_check_fragment_error_button">Back</string>
|
<string name="storage_check_fragment_error_button">Back</string>
|
||||||
|
|
||||||
|
<string name="storage_webdav_option_title">WebDAV Cloud (beta)</string>
|
||||||
|
<string name="storage_webdav_option_summary">Integrated direct WebDAV access</string>
|
||||||
|
<string name="storage_webdav_config_url">WebDAV URL</string>
|
||||||
|
<string name="storage_webdav_config_user">User name</string>
|
||||||
|
<string name="storage_webdav_config_pass">Password</string>
|
||||||
|
<string name="storage_webdav_config_button">Use WebDAV cloud</string>
|
||||||
|
<string name="storage_webdav_config_malformed_url">Invalid WebDAV URL</string>
|
||||||
|
<string name="storage_webdav_name">WebDAV %s</string>
|
||||||
|
|
||||||
<!-- Recovery Code -->
|
<!-- Recovery Code -->
|
||||||
<string name="recovery_code_title">Recovery code</string>
|
<string name="recovery_code_title">Recovery code</string>
|
||||||
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>
|
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>
|
||||||
|
|
|
@ -8,13 +8,15 @@ import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
||||||
import com.stevesoltys.seedvault.header.headerModule
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||||
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
|
||||||
import com.stevesoltys.seedvault.restore.install.installModule
|
import com.stevesoltys.seedvault.restore.install.installModule
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.backupModule
|
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||||
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.KoinApplication
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
class TestApp : App() {
|
class TestApp : App() {
|
||||||
|
@ -31,14 +33,16 @@ class TestApp : App() {
|
||||||
single { SettingsManager(this@TestApp) }
|
single { SettingsManager(this@TestApp) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startKoin() = startKoin {
|
override fun startKoin(): KoinApplication {
|
||||||
|
stopKoin()
|
||||||
|
return startKoin {
|
||||||
androidContext(this@TestApp)
|
androidContext(this@TestApp)
|
||||||
modules(
|
modules(
|
||||||
listOf(
|
listOf(
|
||||||
testCryptoModule,
|
testCryptoModule,
|
||||||
headerModule,
|
headerModule,
|
||||||
metadataModule,
|
metadataModule,
|
||||||
documentsProviderModule, // storage plugin
|
storagePluginModuleSaf, // storage plugin
|
||||||
backupModule,
|
backupModule,
|
||||||
restoreModule,
|
restoreModule,
|
||||||
installModule,
|
installModule,
|
||||||
|
@ -46,4 +50,5 @@ class TestApp : App() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import io.mockk.mockkStatic
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class StoragePluginTest : BackupTest() {
|
internal class StoragePluginTest : BackupTest() {
|
||||||
|
|
||||||
private val storage = mockk<DocumentsStorage>()
|
private val storage = mockk<DocumentsStorage>()
|
||||||
|
@ -39,7 +38,7 @@ internal class StoragePluginTest : BackupTest() {
|
||||||
// get current set dir and for that the current token
|
// get current set dir and for that the current token
|
||||||
every { storage getProperty "currentToken" } returns token
|
every { storage getProperty "currentToken" } returns token
|
||||||
every { settingsManager.getToken() } returns token
|
every { settingsManager.getToken() } returns token
|
||||||
every { storage getProperty "storage" } returns null // just to check if isUsb
|
every { storage getProperty "safStorage" } returns null // just to check if isUsb
|
||||||
coEvery { storage.getSetDir(token) } returns setDir
|
coEvery { storage.getSetDir(token) } returns setDir
|
||||||
// delete contents of current set dir
|
// delete contents of current set dir
|
||||||
coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile)
|
coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile)
|
||||||
|
|
|
@ -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.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||||
|
@ -38,7 +39,6 @@ import java.nio.file.Path
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class ApkBackupRestoreTest : TransportTest() {
|
internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
|
|
||||||
private val pm: PackageManager = mockk()
|
private val pm: PackageManager = mockk()
|
||||||
|
@ -46,16 +46,17 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
every { packageManager } returns pm
|
every { packageManager } returns pm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||||
private val storagePlugin: StoragePlugin = mockk()
|
private val storagePlugin: StoragePlugin<*> = mockk()
|
||||||
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||||
private val apkInstaller: ApkInstaller = mockk()
|
private val apkInstaller: ApkInstaller = mockk()
|
||||||
|
|
||||||
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
|
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
|
||||||
private val apkRestore: ApkRestore = ApkRestore(
|
private val apkRestore: ApkRestore = ApkRestore(
|
||||||
context = strictContext,
|
context = strictContext,
|
||||||
storagePlugin = storagePlugin,
|
pluginManager = storagePluginManager,
|
||||||
legacyStoragePlugin = legacyStoragePlugin,
|
legacyStoragePlugin = legacyStoragePlugin,
|
||||||
crypto = crypto,
|
crypto = crypto,
|
||||||
splitCompatChecker = splitCompatChecker,
|
splitCompatChecker = splitCompatChecker,
|
||||||
|
@ -90,6 +91,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
mockkStatic(PackageUtils::class)
|
mockkStatic(PackageUtils::class)
|
||||||
|
every { storagePluginManager.appPlugin } returns storagePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
|
@ -41,7 +42,6 @@ import java.io.IOException
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
internal class ApkRestoreTest : TransportTest() {
|
internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
|
@ -49,18 +49,19 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
private val strictContext: Context = mockk<Context>().apply {
|
private val strictContext: Context = mockk<Context>().apply {
|
||||||
every { packageManager } returns pm
|
every { packageManager } returns pm
|
||||||
}
|
}
|
||||||
private val storagePlugin: StoragePlugin = mockk()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
private val storagePlugin: StoragePlugin<*> = mockk()
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||||
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||||
private val apkInstaller: ApkInstaller = mockk()
|
private val apkInstaller: ApkInstaller = mockk()
|
||||||
|
|
||||||
private val apkRestore: ApkRestore = ApkRestore(
|
private val apkRestore: ApkRestore = ApkRestore(
|
||||||
strictContext,
|
context = strictContext,
|
||||||
storagePlugin,
|
pluginManager = storagePluginManager,
|
||||||
legacyStoragePlugin,
|
legacyStoragePlugin = legacyStoragePlugin,
|
||||||
crypto,
|
crypto = crypto,
|
||||||
splitCompatChecker,
|
splitCompatChecker = splitCompatChecker,
|
||||||
apkInstaller
|
apkInstaller = apkInstaller,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val icon: Drawable = mockk()
|
private val icon: Drawable = mockk()
|
||||||
|
@ -85,6 +86,8 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
init {
|
init {
|
||||||
// as we don't do strict signature checking, we can use a relaxed mock
|
// as we don't do strict signature checking, we can use a relaxed mock
|
||||||
packageInfo.signingInfo = mockk(relaxed = true)
|
packageInfo.signingInfo = mockk(relaxed = true)
|
||||||
|
|
||||||
|
every { storagePluginManager.appPlugin } returns storagePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -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.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
||||||
|
@ -46,7 +47,6 @@ import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class CoordinatorIntegrationTest : TransportTest() {
|
internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
|
|
||||||
private val inputFactory = mockk<InputFactory>()
|
private val inputFactory = mockk<InputFactory>()
|
||||||
|
@ -58,18 +58,20 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
||||||
private val notificationManager = mockk<BackupNotificationManager>()
|
private val notificationManager = mockk<BackupNotificationManager>()
|
||||||
private val dbManager = TestKvDbManager()
|
private val dbManager = TestKvDbManager()
|
||||||
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||||
private val backupPlugin = mockk<StoragePlugin>()
|
private val backupPlugin = mockk<StoragePlugin<*>>()
|
||||||
private val kvBackup =
|
private val kvBackup =
|
||||||
KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager)
|
KVBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl, dbManager)
|
||||||
private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl)
|
private val fullBackup =
|
||||||
|
FullBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl)
|
||||||
private val apkBackup = mockk<ApkBackup>()
|
private val apkBackup = mockk<ApkBackup>()
|
||||||
private val packageService: PackageService = mockk()
|
private val packageService: PackageService = mockk()
|
||||||
private val backup = BackupCoordinator(
|
private val backup = BackupCoordinator(
|
||||||
context,
|
context,
|
||||||
backupPlugin,
|
storagePluginManager,
|
||||||
kvBackup,
|
kvBackup,
|
||||||
fullBackup,
|
fullBackup,
|
||||||
clock,
|
clock,
|
||||||
|
@ -80,7 +82,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
)
|
)
|
||||||
|
|
||||||
private val kvRestore = KVRestore(
|
private val kvRestore = KVRestore(
|
||||||
backupPlugin,
|
storagePluginManager,
|
||||||
legacyPlugin,
|
legacyPlugin,
|
||||||
outputFactory,
|
outputFactory,
|
||||||
headerReader,
|
headerReader,
|
||||||
|
@ -88,14 +90,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
dbManager
|
dbManager
|
||||||
)
|
)
|
||||||
private val fullRestore =
|
private val fullRestore =
|
||||||
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||||
private val restore = RestoreCoordinator(
|
private val restore = RestoreCoordinator(
|
||||||
context,
|
context,
|
||||||
crypto,
|
crypto,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
metadataManager,
|
metadataManager,
|
||||||
notificationManager,
|
notificationManager,
|
||||||
backupPlugin,
|
storagePluginManager,
|
||||||
kvRestore,
|
kvRestore,
|
||||||
fullRestore,
|
fullRestore,
|
||||||
metadataReader
|
metadataReader
|
||||||
|
@ -113,6 +115,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
// as we use real crypto, we need a real name for packageInfo
|
// as we use real crypto, we need a real name for packageInfo
|
||||||
private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
|
private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { storagePluginManager.appPlugin } returns backupPlugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
||||||
val value = CapturingSlot<ByteArray>()
|
val value = CapturingSlot<ByteArray>()
|
||||||
|
|
|
@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.slot
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
@ -70,13 +71,24 @@ internal abstract class TransportTest {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
mockkStatic(Log::class)
|
mockkStatic(Log::class)
|
||||||
|
val logTagSlot = slot<String>()
|
||||||
|
val logMsgSlot = slot<String>()
|
||||||
|
val logExSlot = slot<Throwable>()
|
||||||
every { Log.v(any(), any()) } returns 0
|
every { Log.v(any(), any()) } returns 0
|
||||||
every { Log.d(any(), any()) } returns 0
|
every { Log.d(capture(logTagSlot), capture(logMsgSlot)) } answers {
|
||||||
|
println("${logTagSlot.captured} - ${logMsgSlot.captured}")
|
||||||
|
0
|
||||||
|
}
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
every { Log.i(any(), any()) } returns 0
|
every { Log.i(any(), any()) } returns 0
|
||||||
every { Log.w(any(), ofType(String::class)) } returns 0
|
every { Log.w(any(), ofType(String::class)) } returns 0
|
||||||
every { Log.w(any(), ofType(String::class), any()) } returns 0
|
every { Log.w(any(), ofType(String::class), any()) } returns 0
|
||||||
every { Log.e(any(), any()) } returns 0
|
every { Log.e(any(), any()) } returns 0
|
||||||
every { Log.e(any(), any(), any()) } returns 0
|
every { Log.e(capture(logTagSlot), capture(logMsgSlot), capture(logExSlot)) } answers {
|
||||||
|
println("${logTagSlot.captured} - ${logMsgSlot.captured} ${logExSlot.captured}")
|
||||||
|
logExSlot.captured.printStackTrace()
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,9 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
|
@ -33,10 +34,9 @@ import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class BackupCoordinatorTest : BackupTest() {
|
internal class BackupCoordinatorTest : BackupTest() {
|
||||||
|
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val pluginManager = mockk<StoragePluginManager>()
|
||||||
private val kv = mockk<KVBackup>()
|
private val kv = mockk<KVBackup>()
|
||||||
private val full = mockk<FullBackup>()
|
private val full = mockk<FullBackup>()
|
||||||
private val apkBackup = mockk<ApkBackup>()
|
private val apkBackup = mockk<ApkBackup>()
|
||||||
|
@ -44,27 +44,32 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
private val packageService = mockk<PackageService>()
|
private val packageService = mockk<PackageService>()
|
||||||
|
|
||||||
private val backup = BackupCoordinator(
|
private val backup = BackupCoordinator(
|
||||||
context,
|
context = context,
|
||||||
plugin,
|
pluginManager = pluginManager,
|
||||||
kv,
|
kv = kv,
|
||||||
full,
|
full = full,
|
||||||
clock,
|
clock = clock,
|
||||||
packageService,
|
packageService = packageService,
|
||||||
metadataManager,
|
metadataManager = metadataManager,
|
||||||
settingsManager,
|
settingsManager = settingsManager,
|
||||||
notificationManager
|
nm = notificationManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
private val metadataOutputStream = mockk<OutputStream>()
|
private val metadataOutputStream = mockk<OutputStream>()
|
||||||
private val fileDescriptor: ParcelFileDescriptor = mockk()
|
private val fileDescriptor: ParcelFileDescriptor = mockk()
|
||||||
private val packageMetadata: PackageMetadata = mockk()
|
private val packageMetadata: PackageMetadata = mockk()
|
||||||
private val storage = Storage(
|
private val safStorage = SafStorage(
|
||||||
uri = Uri.EMPTY,
|
config = Uri.EMPTY,
|
||||||
name = getRandomString(),
|
name = getRandomString(),
|
||||||
isUsb = false,
|
isUsb = false,
|
||||||
requiresNetwork = false
|
requiresNetwork = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { pluginManager.appPlugin } returns plugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
|
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
|
||||||
expectStartNewRestoreSet()
|
expectStartNewRestoreSet()
|
||||||
|
@ -90,7 +95,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
expectStartNewRestoreSet()
|
expectStartNewRestoreSet()
|
||||||
coEvery { plugin.initializeDevice() } throws IOException()
|
coEvery { plugin.initializeDevice() } throws IOException()
|
||||||
every { metadataManager.requiresInit } returns maybeTrue
|
every { metadataManager.requiresInit } returns maybeTrue
|
||||||
every { settingsManager.canDoBackupNow() } returns !maybeTrue
|
every { pluginManager.canDoBackupNow() } returns !maybeTrue
|
||||||
every { notificationManager.onBackupError() } just Runs
|
every { notificationManager.onBackupError() } just Runs
|
||||||
|
|
||||||
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
||||||
|
@ -109,7 +114,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
expectStartNewRestoreSet()
|
expectStartNewRestoreSet()
|
||||||
coEvery { plugin.initializeDevice() } throws IOException()
|
coEvery { plugin.initializeDevice() } throws IOException()
|
||||||
every { metadataManager.requiresInit } returns false
|
every { metadataManager.requiresInit } returns false
|
||||||
every { settingsManager.canDoBackupNow() } returns false
|
every { pluginManager.canDoBackupNow() } returns false
|
||||||
|
|
||||||
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
||||||
|
|
||||||
|
@ -125,7 +130,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking {
|
fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking {
|
||||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||||
|
|
||||||
every { settingsManager.canDoBackupNow() } returns true
|
every { pluginManager.canDoBackupNow() } returns true
|
||||||
every { metadataManager.requiresInit } returns true
|
every { metadataManager.requiresInit } returns true
|
||||||
|
|
||||||
// start new restore set
|
// start new restore set
|
||||||
|
@ -224,7 +229,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
every { kv.getCurrentSize() } returns 42L
|
every { kv.getCurrentSize() } returns 42L
|
||||||
|
|
||||||
coEvery { kv.finishBackup() } returns TRANSPORT_OK
|
coEvery { kv.finishBackup() } returns TRANSPORT_OK
|
||||||
every { settingsManager.canDoBackupNow() } returns false
|
every { pluginManager.canDoBackupNow() } returns false
|
||||||
|
|
||||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||||
}
|
}
|
||||||
|
@ -290,7 +295,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
)
|
)
|
||||||
} just Runs
|
} just Runs
|
||||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { pluginManager.storageProperties } returns safStorage
|
||||||
every { settingsManager.useMeteredNetwork } returns false
|
every { settingsManager.useMeteredNetwork } returns false
|
||||||
every { metadataOutputStream.close() } just Runs
|
every { metadataOutputStream.close() } just Runs
|
||||||
|
|
||||||
|
@ -340,7 +345,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
)
|
)
|
||||||
} just Runs
|
} just Runs
|
||||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { pluginManager.storageProperties } returns safStorage
|
||||||
every { settingsManager.useMeteredNetwork } returns false
|
every { settingsManager.useMeteredNetwork } returns false
|
||||||
every { metadataOutputStream.close() } just Runs
|
every { metadataOutputStream.close() } just Runs
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
import com.stevesoltys.seedvault.header.getADForFull
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -21,16 +22,20 @@ import java.io.FileInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class FullBackupTest : BackupTest() {
|
internal class FullBackupTest : BackupTest() {
|
||||||
|
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
private val backup = FullBackup(plugin, settingsManager, inputFactory, crypto)
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
|
private val backup = FullBackup(storagePluginManager, settingsManager, inputFactory, crypto)
|
||||||
|
|
||||||
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
|
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
|
||||||
private val inputStream = mockk<FileInputStream>()
|
private val inputStream = mockk<FileInputStream>()
|
||||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { storagePluginManager.appPlugin } returns plugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `has no initial state`() {
|
fun `has no initial state`() {
|
||||||
assertFalse(backup.hasState())
|
assertFalse(backup.hasState())
|
||||||
|
|
|
@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
import com.stevesoltys.seedvault.header.getADForKV
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import io.mockk.CapturingSlot
|
import io.mockk.CapturingSlot
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
@ -30,22 +31,26 @@ import java.io.ByteArrayInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class KVBackupTest : BackupTest() {
|
internal class KVBackupTest : BackupTest() {
|
||||||
|
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val pluginManager = mockk<StoragePluginManager>()
|
||||||
private val dataInput = mockk<BackupDataInput>()
|
private val dataInput = mockk<BackupDataInput>()
|
||||||
private val dbManager = mockk<KvDbManager>()
|
private val dbManager = mockk<KvDbManager>()
|
||||||
|
|
||||||
private val backup = KVBackup(plugin, settingsManager, inputFactory, crypto, dbManager)
|
private val backup = KVBackup(pluginManager, settingsManager, inputFactory, crypto, dbManager)
|
||||||
|
|
||||||
private val db = mockk<KVDb>()
|
private val db = mockk<KVDb>()
|
||||||
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
private val packageName = packageInfo.packageName
|
private val packageName = packageInfo.packageName
|
||||||
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
|
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
|
||||||
private val dataValue = Random.nextBytes(23)
|
private val dataValue = Random.nextBytes(23)
|
||||||
private val dbBytes = Random.nextBytes(42)
|
private val dbBytes = Random.nextBytes(42)
|
||||||
private val inputStream = ByteArrayInputStream(dbBytes)
|
private val inputStream = ByteArrayInputStream(dbBytes)
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { pluginManager.appPlugin } returns plugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `has no initial state`() {
|
fun `has no initial state`() {
|
||||||
assertFalse(backup.hasState())
|
assertFalse(backup.hasState())
|
||||||
|
@ -231,7 +236,7 @@ internal class KVBackupTest : BackupTest() {
|
||||||
every { dbManager.existsDb(pmPackageInfo.packageName) } returns false
|
every { dbManager.existsDb(pmPackageInfo.packageName) } returns false
|
||||||
every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name
|
every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name
|
||||||
every { dbManager.getDb(pmPackageInfo.packageName) } returns db
|
every { dbManager.getDb(pmPackageInfo.packageName) } returns db
|
||||||
every { settingsManager.canDoBackupNow() } returns false
|
every { pluginManager.canDoBackupNow() } returns false
|
||||||
every { db.put(key, dataValue) } just Runs
|
every { db.put(key, dataValue) } just Runs
|
||||||
getDataInput(listOf(true, false))
|
getDataInput(listOf(true, false))
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
import com.stevesoltys.seedvault.header.getADForFull
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import io.mockk.CapturingSlot
|
import io.mockk.CapturingSlot
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
@ -31,17 +32,27 @@ import java.io.IOException
|
||||||
import java.security.GeneralSecurityException
|
import java.security.GeneralSecurityException
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class FullRestoreTest : RestoreTest() {
|
internal class FullRestoreTest : RestoreTest() {
|
||||||
|
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||||
private val restore = FullRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto)
|
private val restore = FullRestore(
|
||||||
|
pluginManager = storagePluginManager,
|
||||||
|
legacyPlugin = legacyPlugin,
|
||||||
|
outputFactory = outputFactory,
|
||||||
|
headerReader = headerReader,
|
||||||
|
crypto = crypto,
|
||||||
|
)
|
||||||
|
|
||||||
private val encrypted = getRandomByteArray()
|
private val encrypted = getRandomByteArray()
|
||||||
private val outputStream = ByteArrayOutputStream()
|
private val outputStream = ByteArrayOutputStream()
|
||||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { storagePluginManager.appPlugin } returns plugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `has no initial state`() {
|
fun `has no initial state`() {
|
||||||
assertFalse(restore.hasState())
|
assertFalse(restore.hasState())
|
||||||
|
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
import com.stevesoltys.seedvault.header.getADForKV
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.KVDb
|
import com.stevesoltys.seedvault.transport.backup.KVDb
|
||||||
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
|
@ -33,15 +34,22 @@ import java.security.GeneralSecurityException
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class KVRestoreTest : RestoreTest() {
|
internal class KVRestoreTest : RestoreTest() {
|
||||||
|
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||||
private val dbManager = mockk<KvDbManager>()
|
private val dbManager = mockk<KvDbManager>()
|
||||||
private val output = mockk<BackupDataOutput>()
|
private val output = mockk<BackupDataOutput>()
|
||||||
private val restore =
|
private val restore = KVRestore(
|
||||||
KVRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto, dbManager)
|
pluginManager = storagePluginManager,
|
||||||
|
legacyPlugin = legacyPlugin,
|
||||||
|
outputFactory = outputFactory,
|
||||||
|
headerReader = headerReader,
|
||||||
|
crypto = crypto,
|
||||||
|
dbManager = dbManager,
|
||||||
|
)
|
||||||
|
|
||||||
private val db = mockk<KVDb>()
|
private val db = mockk<KVDb>()
|
||||||
private val ad = getADForKV(VERSION, packageInfo.packageName)
|
private val ad = getADForKV(VERSION, packageInfo.packageName)
|
||||||
|
@ -60,6 +68,8 @@ internal class KVRestoreTest : RestoreTest() {
|
||||||
init {
|
init {
|
||||||
// for InputStream#readBytes()
|
// for InputStream#readBytes()
|
||||||
mockkStatic("kotlin.io.ByteStreamsKt")
|
mockkStatic("kotlin.io.ByteStreamsKt")
|
||||||
|
|
||||||
|
every { storagePluginManager.appPlugin } returns plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -180,7 +190,6 @@ internal class KVRestoreTest : RestoreTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("Deprecation")
|
|
||||||
fun `v0 listing records throws`() = runBlocking {
|
fun `v0 listing records throws`() = runBlocking {
|
||||||
restore.initializeState(0x00, token, name, packageInfo)
|
restore.initializeState(0x00, token, name, packageInfo)
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,8 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
|
@ -35,29 +36,29 @@ import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class RestoreCoordinatorTest : TransportTest() {
|
internal class RestoreCoordinatorTest : TransportTest() {
|
||||||
|
|
||||||
private val notificationManager: BackupNotificationManager = mockk()
|
private val notificationManager: BackupNotificationManager = mockk()
|
||||||
private val plugin = mockk<StoragePlugin>()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
private val plugin = mockk<StoragePlugin<*>>()
|
||||||
private val kv = mockk<KVRestore>()
|
private val kv = mockk<KVRestore>()
|
||||||
private val full = mockk<FullRestore>()
|
private val full = mockk<FullRestore>()
|
||||||
private val metadataReader = mockk<MetadataReader>()
|
private val metadataReader = mockk<MetadataReader>()
|
||||||
|
|
||||||
private val restore = RestoreCoordinator(
|
private val restore = RestoreCoordinator(
|
||||||
context,
|
context = context,
|
||||||
crypto,
|
crypto = crypto,
|
||||||
settingsManager,
|
settingsManager = settingsManager,
|
||||||
metadataManager,
|
metadataManager = metadataManager,
|
||||||
notificationManager,
|
notificationManager = notificationManager,
|
||||||
plugin,
|
pluginManager = storagePluginManager,
|
||||||
kv,
|
kv = kv,
|
||||||
full,
|
full = full,
|
||||||
metadataReader
|
metadataReader = metadataReader,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val inputStream = mockk<InputStream>()
|
private val inputStream = mockk<InputStream>()
|
||||||
private val storage: Storage = mockk()
|
private val safStorage: SafStorage = mockk()
|
||||||
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
|
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
|
||||||
private val packageInfoArray = arrayOf(packageInfo)
|
private val packageInfoArray = arrayOf(packageInfo)
|
||||||
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
|
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
|
||||||
|
@ -71,6 +72,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
||||||
init {
|
init {
|
||||||
metadata.packageMetadataMap[packageInfo2.packageName] =
|
metadata.packageMetadataMap[packageInfo2.packageName] =
|
||||||
PackageMetadata(backupType = BackupType.FULL)
|
PackageMetadata(backupType = BackupType.FULL)
|
||||||
|
|
||||||
|
every { storagePluginManager.appPlugin } returns plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -164,10 +167,10 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `startRestore() optimized auto-restore with removed storage shows notification`() =
|
fun `startRestore() optimized auto-restore with removed storage shows notification`() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { storagePluginManager.storageProperties } returns safStorage
|
||||||
every { storage.isUnavailableUsb(context) } returns true
|
every { safStorage.isUnavailableUsb(context) } returns true
|
||||||
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
|
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
|
||||||
every { storage.name } returns storageName
|
every { safStorage.name } returns storageName
|
||||||
every {
|
every {
|
||||||
notificationManager.onRemovableStorageNotAvailableForRestore(
|
notificationManager.onRemovableStorageNotAvailableForRestore(
|
||||||
packageName,
|
packageName,
|
||||||
|
@ -188,8 +191,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `startRestore() optimized auto-restore with available storage shows no notification`() =
|
fun `startRestore() optimized auto-restore with available storage shows no notification`() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { storagePluginManager.storageProperties } returns safStorage
|
||||||
every { storage.isUnavailableUsb(context) } returns false
|
every { safStorage.isUnavailableUsb(context) } returns false
|
||||||
|
|
||||||
restore.beforeStartRestore(metadata)
|
restore.beforeStartRestore(metadata)
|
||||||
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
|
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
|
||||||
|
@ -204,8 +207,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `startRestore() with removed storage shows no notification`() = runBlocking {
|
fun `startRestore() with removed storage shows no notification`() = runBlocking {
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { storagePluginManager.storageProperties } returns safStorage
|
||||||
every { storage.isUnavailableUsb(context) } returns true
|
every { safStorage.isUnavailableUsb(context) } returns true
|
||||||
every { metadataManager.getPackageMetadata(packageName) } returns null
|
every { metadataManager.getPackageMetadata(packageName) } returns null
|
||||||
|
|
||||||
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
|
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
|
||||||
|
|
|
@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.toByteArrayFromHex
|
import com.stevesoltys.seedvault.toByteArrayFromHex
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
||||||
|
@ -35,7 +36,6 @@ import javax.crypto.spec.SecretKeySpec
|
||||||
/**
|
/**
|
||||||
* Tests that we can still restore Version 0 backups with current code.
|
* Tests that we can still restore Version 0 backups with current code.
|
||||||
*/
|
*/
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class RestoreV0IntegrationTest : TransportTest() {
|
internal class RestoreV0IntegrationTest : TransportTest() {
|
||||||
|
|
||||||
private val outputFactory = mockk<OutputFactory>()
|
private val outputFactory = mockk<OutputFactory>()
|
||||||
|
@ -49,30 +49,31 @@ internal class RestoreV0IntegrationTest : TransportTest() {
|
||||||
private val dbManager = mockk<KvDbManager>()
|
private val dbManager = mockk<KvDbManager>()
|
||||||
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
||||||
private val notificationManager = mockk<BackupNotificationManager>()
|
private val notificationManager = mockk<BackupNotificationManager>()
|
||||||
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||||
private val backupPlugin = mockk<StoragePlugin>()
|
private val backupPlugin = mockk<StoragePlugin<*>>()
|
||||||
private val kvRestore = KVRestore(
|
private val kvRestore = KVRestore(
|
||||||
backupPlugin,
|
pluginManager = storagePluginManager,
|
||||||
legacyPlugin,
|
legacyPlugin = legacyPlugin,
|
||||||
outputFactory,
|
outputFactory = outputFactory,
|
||||||
headerReader,
|
headerReader = headerReader,
|
||||||
cryptoImpl,
|
crypto = cryptoImpl,
|
||||||
dbManager
|
dbManager = dbManager,
|
||||||
)
|
)
|
||||||
private val fullRestore =
|
private val fullRestore =
|
||||||
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||||
private val restore = RestoreCoordinator(
|
private val restore = RestoreCoordinator(
|
||||||
context,
|
context = context,
|
||||||
crypto,
|
crypto = crypto,
|
||||||
settingsManager,
|
settingsManager = settingsManager,
|
||||||
metadataManager,
|
metadataManager = metadataManager,
|
||||||
notificationManager,
|
notificationManager = notificationManager,
|
||||||
backupPlugin,
|
pluginManager = storagePluginManager,
|
||||||
kvRestore,
|
kv = kvRestore,
|
||||||
fullRestore,
|
full = fullRestore,
|
||||||
metadataReader
|
metadataReader = metadataReader,
|
||||||
).apply { beforeStartRestore(metadata.copy(version = 0x00)) }
|
).apply { beforeStartRestore(metadata.copy(version = 0x00)) }
|
||||||
|
|
||||||
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
|
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
|
||||||
|
@ -116,6 +117,10 @@ internal class RestoreV0IntegrationTest : TransportTest() {
|
||||||
private val key2 = "RestoreKey2"
|
private val key2 = "RestoreKey2"
|
||||||
private val key264 = key2.encodeBase64()
|
private val key264 = key2.encodeBase64()
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { storagePluginManager.appPlugin } returns backupPlugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
||||||
val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" +
|
val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" +
|
||||||
|
|
|
@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
@ -32,7 +33,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
|
|
||||||
private val packageService: PackageService = mockk()
|
private val packageService: PackageService = mockk()
|
||||||
private val apkBackup: ApkBackup = mockk()
|
private val apkBackup: ApkBackup = mockk()
|
||||||
private val plugin: StoragePlugin = mockk()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
private val plugin: StoragePlugin<*> = mockk()
|
||||||
private val nm: BackupNotificationManager = mockk()
|
private val nm: BackupNotificationManager = mockk()
|
||||||
|
|
||||||
private val apkBackupManager = ApkBackupManager(
|
private val apkBackupManager = ApkBackupManager(
|
||||||
|
@ -41,13 +43,17 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
metadataManager = metadataManager,
|
metadataManager = metadataManager,
|
||||||
packageService = packageService,
|
packageService = packageService,
|
||||||
apkBackup = apkBackup,
|
apkBackup = apkBackup,
|
||||||
plugin = plugin,
|
pluginManager = storagePluginManager,
|
||||||
nm = nm,
|
nm = nm,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val metadataOutputStream = mockk<OutputStream>()
|
private val metadataOutputStream = mockk<OutputStream>()
|
||||||
private val packageMetadata: PackageMetadata = mockk()
|
private val packageMetadata: PackageMetadata = mockk()
|
||||||
|
|
||||||
|
init {
|
||||||
|
every { storagePluginManager.appPlugin } returns plugin
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
|
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
|
|
|
@ -38,7 +38,6 @@ import java.io.OutputStream
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class ApkBackupTest : BackupTest() {
|
internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
private val pm: PackageManager = mockk()
|
private val pm: PackageManager = mockk()
|
||||||
|
|
17
libs/dav4jvm/Android.bp
Normal file
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 settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
|
||||||
val storageBackup: StorageBackup by lazy {
|
val storageBackup: StorageBackup by lazy {
|
||||||
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
|
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
|
||||||
StorageBackup(this, plugin)
|
StorageBackup(this, { plugin })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|
|
@ -98,8 +98,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
||||||
fun setBackupLocation(uri: Uri?) {
|
fun setBackupLocation(uri: Uri?) {
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
storageBackup.deleteAllSnapshots()
|
storageBackup.init()
|
||||||
storageBackup.clearCache()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settingsManager.setBackupLocation(uri)
|
settingsManager.setBackupLocation(uri)
|
||||||
|
|
|
@ -42,12 +42,12 @@ class TestSafStoragePlugin(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getChunkOutputStream(chunkId: String): OutputStream {
|
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||||
if (getLocationUri() == null) return nullStream
|
if (getLocationUri() == null) return nullStream
|
||||||
return super.getChunkOutputStream(chunkId)
|
return super.getChunkOutputStream(chunkId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||||
if (root == null) return nullStream
|
if (root == null) return nullStream
|
||||||
return super.getBackupSnapshotOutputStream(timestamp)
|
return super.getBackupSnapshotOutputStream(timestamp)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ public sealed class SnapshotResult {
|
||||||
public data class StoredSnapshot(
|
public data class StoredSnapshot(
|
||||||
/**
|
/**
|
||||||
* The unique ID of the current device/user combination chosen by the [StoragePlugin].
|
* The unique ID of the current device/user combination chosen by the [StoragePlugin].
|
||||||
|
* It may include an '.sv' extension.
|
||||||
*/
|
*/
|
||||||
public val userId: String,
|
public val userId: String,
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -36,10 +36,9 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
private const val TAG = "StorageBackup"
|
private const val TAG = "StorageBackup"
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
public class StorageBackup(
|
public class StorageBackup(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val plugin: StoragePlugin,
|
private val pluginGetter: () -> StoragePlugin,
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -50,18 +49,18 @@ public class StorageBackup(
|
||||||
private val uriStore by lazy { db.getUriStore() }
|
private val uriStore by lazy { db.getUriStore() }
|
||||||
|
|
||||||
private val mediaScanner by lazy { MediaScanner(context) }
|
private val mediaScanner by lazy { MediaScanner(context) }
|
||||||
private val snapshotRetriever = SnapshotRetriever(plugin)
|
private val snapshotRetriever = SnapshotRetriever(pluginGetter)
|
||||||
private val chunksCacheRepopulater = ChunksCacheRepopulater(db, plugin, snapshotRetriever)
|
private val chunksCacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever)
|
||||||
private val backup by lazy {
|
private val backup by lazy {
|
||||||
val documentScanner = DocumentScanner(context)
|
val documentScanner = DocumentScanner(context)
|
||||||
val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner)
|
val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner)
|
||||||
Backup(context, db, fileScanner, plugin, chunksCacheRepopulater)
|
Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater)
|
||||||
}
|
}
|
||||||
private val restore by lazy {
|
private val restore by lazy {
|
||||||
Restore(context, plugin, snapshotRetriever, FileRestore(context, mediaScanner))
|
Restore(context, pluginGetter, snapshotRetriever, FileRestore(context, mediaScanner))
|
||||||
}
|
}
|
||||||
private val retention = RetentionManager(context)
|
private val retention = RetentionManager(context)
|
||||||
private val pruner by lazy { Pruner(db, retention, plugin, snapshotRetriever) }
|
private val pruner by lazy { Pruner(db, retention, pluginGetter, snapshotRetriever) }
|
||||||
|
|
||||||
private val backupRunning = AtomicBoolean(false)
|
private val backupRunning = AtomicBoolean(false)
|
||||||
private val restoreRunning = AtomicBoolean(false)
|
private val restoreRunning = AtomicBoolean(false)
|
||||||
|
@ -104,6 +103,16 @@ public class StorageBackup(
|
||||||
list.joinToString(", ", limit = 5)
|
list.joinToString(", ", limit = 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the storage is set-up to receive backups and deletes all snapshots
|
||||||
|
* (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]).
|
||||||
|
*/
|
||||||
|
public suspend fun init() {
|
||||||
|
pluginGetter().init()
|
||||||
|
deleteAllSnapshots()
|
||||||
|
clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run this on a new storage location to ensure that there are no old snapshots
|
* Run this on a new storage location to ensure that there are no old snapshots
|
||||||
* (potentially encrypted with an old key) laying around.
|
* (potentially encrypted with an old key) laying around.
|
||||||
|
@ -113,9 +122,9 @@ public class StorageBackup(
|
||||||
*/
|
*/
|
||||||
public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) {
|
public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
plugin.getCurrentBackupSnapshots().forEach {
|
pluginGetter().getCurrentBackupSnapshots().forEach {
|
||||||
try {
|
try {
|
||||||
plugin.deleteBackupSnapshot(it)
|
pluginGetter().deleteBackupSnapshot(it)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error deleting snapshot $it", e)
|
Log.e(TAG, "Error deleting snapshot $it", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,13 @@ import javax.crypto.SecretKey
|
||||||
|
|
||||||
public interface StoragePlugin {
|
public interface StoragePlugin {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the storage location for storing backups.
|
||||||
|
* Call this before using the [StoragePlugin] for the first time.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
public suspend fun init()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called before starting a backup run to ensure that all cached chunks are still available.
|
* Called before starting a backup run to ensure that all cached chunks are still available.
|
||||||
* Plugins should use this opportunity
|
* Plugins should use this opportunity
|
||||||
|
@ -32,10 +39,10 @@ public interface StoragePlugin {
|
||||||
public fun hasMasterKey(): Boolean
|
public fun hasMasterKey(): Boolean
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
public fun getChunkOutputStream(chunkId: String): OutputStream
|
public suspend fun getChunkOutputStream(chunkId: String): OutputStream
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
public fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
|
public suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
|
||||||
|
|
||||||
/* Restore */
|
/* Restore */
|
||||||
|
|
||||||
|
|
|
@ -35,12 +35,11 @@ internal class BackupResult(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class Backup(
|
internal class Backup(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val db: Db,
|
private val db: Db,
|
||||||
private val fileScanner: FileScanner,
|
private val fileScanner: FileScanner,
|
||||||
private val storagePlugin: StoragePlugin,
|
private val storagePluginGetter: () -> StoragePlugin,
|
||||||
private val cacheRepopulater: ChunksCacheRepopulater,
|
private val cacheRepopulater: ChunksCacheRepopulater,
|
||||||
chunkSizeMax: Int = CHUNK_SIZE_MAX,
|
chunkSizeMax: Int = CHUNK_SIZE_MAX,
|
||||||
private val streamCrypto: StreamCrypto = StreamCrypto,
|
private val streamCrypto: StreamCrypto = StreamCrypto,
|
||||||
|
@ -54,6 +53,7 @@ internal class Backup(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val contentResolver = context.contentResolver
|
private val contentResolver = context.contentResolver
|
||||||
|
private val storagePlugin get() = storagePluginGetter()
|
||||||
private val filesCache = db.getFilesCache()
|
private val filesCache = db.getFilesCache()
|
||||||
private val chunksCache = db.getChunksCache()
|
private val chunksCache = db.getChunksCache()
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ internal class ChunkWriter(
|
||||||
private val buffer = ByteArray(bufferSize)
|
private val buffer = ByteArray(bufferSize)
|
||||||
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
fun writeChunk(
|
suspend fun writeChunk(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
chunks: List<Chunk>,
|
chunks: List<Chunk>,
|
||||||
missingChunkIds: List<String>,
|
missingChunkIds: List<String>,
|
||||||
|
@ -67,7 +67,7 @@ internal class ChunkWriter(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
private fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) {
|
private suspend fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) {
|
||||||
storagePlugin.getChunkOutputStream(chunkId).use { chunkStream ->
|
storagePlugin.getChunkOutputStream(chunkId).use { chunkStream ->
|
||||||
chunkStream.write(VERSION.toInt())
|
chunkStream.write(VERSION.toInt())
|
||||||
val ad = streamCrypto.getAssociatedDataForChunk(chunkId)
|
val ad = streamCrypto.getAssociatedDataForChunk(chunkId)
|
||||||
|
@ -102,7 +102,7 @@ internal class ChunkWriter(
|
||||||
* @return true if the chunk was written or false, if it was present already.
|
* @return true if the chunk was written or false, if it was present already.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
fun writeZipChunk(
|
suspend fun writeZipChunk(
|
||||||
chunk: ZipChunk,
|
chunk: ZipChunk,
|
||||||
zip: ByteArrayOutputStream,
|
zip: ByteArrayOutputStream,
|
||||||
missingChunkIds: List<String>,
|
missingChunkIds: List<String>,
|
||||||
|
|
|
@ -19,10 +19,9 @@ import kotlin.time.toDuration
|
||||||
|
|
||||||
private const val TAG = "ChunksCacheRepopulater"
|
private const val TAG = "ChunksCacheRepopulater"
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
internal class ChunksCacheRepopulater(
|
internal class ChunksCacheRepopulater(
|
||||||
private val db: Db,
|
private val db: Db,
|
||||||
private val storagePlugin: StoragePlugin,
|
private val storagePlugin: () -> StoragePlugin,
|
||||||
private val snapshotRetriever: SnapshotRetriever,
|
private val snapshotRetriever: SnapshotRetriever,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -43,7 +42,7 @@ internal class ChunksCacheRepopulater(
|
||||||
availableChunkIds: HashSet<String>,
|
availableChunkIds: HashSet<String>,
|
||||||
) {
|
) {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
val snapshots = storagePlugin.getCurrentBackupSnapshots().mapNotNull { storedSnapshot ->
|
val snapshots = storagePlugin().getCurrentBackupSnapshots().mapNotNull { storedSnapshot ->
|
||||||
try {
|
try {
|
||||||
snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
|
snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
|
||||||
} catch (e: GeneralSecurityException) {
|
} catch (e: GeneralSecurityException) {
|
||||||
|
@ -63,7 +62,7 @@ internal class ChunksCacheRepopulater(
|
||||||
// delete chunks that are not references by any snapshot anymore
|
// delete chunks that are not references by any snapshot anymore
|
||||||
val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id })
|
val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id })
|
||||||
val deletionDuration = measure {
|
val deletionDuration = measure {
|
||||||
storagePlugin.deleteChunks(chunksToDelete.toList())
|
storagePlugin().deleteChunks(chunksToDelete.toList())
|
||||||
}
|
}
|
||||||
Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration")
|
Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration")
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue