diff --git a/.gitignore b/.gitignore
index ec605697..0146d1a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,7 @@ hs_err_pid*
## Intellij
out/
-lib/
+/lib/
.idea/*
!.idea/runConfigurations*
!.idea/inspectionProfiles*
@@ -33,7 +33,8 @@ local.properties
## NetBeans
**/nbproject/private/
-build/
+/build/
+/app/build/
nbbuild/
dist/
nbdist/
@@ -50,6 +51,3 @@ gradle-app.setting
## Android
gen/
-
-## Prebuilt
-Backup.apk
diff --git a/.idea/runConfigurations/All_unit_tests.xml b/.idea/runConfigurations/All_unit_tests.xml
new file mode 100644
index 00000000..8136a9a1
--- /dev/null
+++ b/.idea/runConfigurations/All_unit_tests.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Instrumentation_Tests.xml b/.idea/runConfigurations/Instrumentation_tests__app.xml
similarity index 93%
rename from .idea/runConfigurations/Instrumentation_Tests.xml
rename to .idea/runConfigurations/Instrumentation_tests__app.xml
index 60200c14..9bc639a6 100644
--- a/.idea/runConfigurations/Instrumentation_Tests.xml
+++ b/.idea/runConfigurations/Instrumentation_tests__app.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_tests__app.xml
similarity index 85%
rename from .idea/runConfigurations/Unit_Tests.xml
rename to .idea/runConfigurations/Unit_tests__app.xml
index 0a2becf3..9d0f07f9 100644
--- a/.idea/runConfigurations/Unit_Tests.xml
+++ b/.idea/runConfigurations/Unit_tests__app.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/Unit_tests__contactsbackup.xml b/.idea/runConfigurations/Unit_tests__contactsbackup.xml
new file mode 100644
index 00000000..cb836a4e
--- /dev/null
+++ b/.idea/runConfigurations/Unit_tests__contactsbackup.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Unit_tests__storage_lib.xml b/.idea/runConfigurations/Unit_tests__storage_lib.xml
new file mode 100644
index 00000000..55889f74
--- /dev/null
+++ b/.idea/runConfigurations/Unit_tests__storage_lib.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/seedvault_storage_lib__assembleRelease_.xml b/.idea/runConfigurations/seedvault_storage_lib__assembleRelease_.xml
new file mode 100644
index 00000000..397b0409
--- /dev/null
+++ b/.idea/runConfigurations/seedvault_storage_lib__assembleRelease_.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
\ No newline at end of file
diff --git a/Android.bp b/Android.bp
index 258391b7..a8f4ab85 100644
--- a/Android.bp
+++ b/Android.bp
@@ -31,9 +31,16 @@ android_app {
"androidx.lifecycle_lifecycle-livedata-ktx",
"androidx-constraintlayout_constraintlayout",
"com.google.android.material_material",
+ // storage
+ "seedvault-lib-storage", // did not manage to add this as transitive dependency
+ "seedvault-lib-tink-android",
+ "androidx.room_room-runtime",
+ "libprotobuf-java-lite",
+ // koin
"seedvault-lib-koin-core", // did not manage to add this as transitive dependency
"seedvault-lib-koin-android",
"seedvault-lib-koin-androidx-viewmodel",
+ // bip39
"seedvault-lib-novacrypto-bip39",
],
manifest: "app/src/main/AndroidManifest.xml",
diff --git a/README.md b/README.md
index 49410804..20c0018a 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
* `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
+* `android.permission.MANAGE_EXTERNAL_STORAGE` to backup and restore files from device storage.
+* `android.permission.ACCESS_MEDIA_LOCATION` to backup original media files e.g. without stripped EXIF metadata.
+* `android.permission.FOREGROUND_SERVICE` to do periodic storage backups without interruption.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
## Contributing
diff --git a/app/build.gradle b/app/build.gradle
index 3159d385..14633d3d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,13 +16,12 @@ def gitDescribe = { ->
}
android {
-
- compileSdkVersion 30
- buildToolsVersion '30.0.2'
+ compileSdkVersion rootProject.ext.compileSdkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
defaultConfig {
minSdkVersion 29 // leave at 29 for robolectric tests
- targetSdkVersion 30
+ targetSdkVersion rootProject.ext.targetSdkVersion
versionNameSuffix "-$gitDescribe"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true'
@@ -44,6 +43,7 @@ android {
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
+ languageVersion = "1.3"
}
testOptions {
unitTests.all {
@@ -81,7 +81,72 @@ android {
buildTypes.debug.signingConfig = signingConfigs.aosp
}
-apply from: '../gradle/dependencies.gradle'
+dependencies {
+ compileOnly rootProject.ext.aosp_libs
+
+ /**
+ * Dependencies in AOSP
+ *
+ * We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
+ * with the AOSP build system and gradle builds are just for more pleasant development.
+ * Using the AOSP versions in gradle builds allows us to spot issues early on.
+ */
+ implementation rootProject.ext.kotlin_libs.std
+ // These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
+ implementation rootProject.ext.kotlin_libs.coroutines
+
+ implementation rootProject.ext.std_libs.androidx_core
+ // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
+ implementation rootProject.ext.std_libs.androidx_fragment
+ implementation rootProject.ext.std_libs.androidx_preference
+ implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
+ implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
+ implementation rootProject.ext.std_libs.androidx_constraintlayout
+ implementation rootProject.ext.std_libs.androidx_documentfile
+ implementation rootProject.ext.std_libs.com_google_android_material
+
+ /**
+ * Storage Dependencies
+ */
+ implementation project(':storage:lib')
+// implementation fileTree(include: ['storage.aar'], dir: "${rootProject.rootDir}/storage/lib/libs")
+
+ /**
+ * External Dependencies
+ *
+ * If the dependencies below are updated,
+ * please make sure to update the prebuilt libraries and the Android.bp files
+ * in the top-level `libs` folder to reflect that.
+ * You can copy these libraries from ~/.gradle/caches/modules-2
+ */
+ // later versions than 2.1.1 require newer kotlin version
+ implementation fileTree(include: ['*.jar'], dir: "${rootProject.rootDir}/libs/koin-android")
+ implementation fileTree(include: ['*.aar'], dir: "${rootProject.rootDir}/libs/koin-android")
+
+ implementation fileTree(include: ['*.jar'], dir: "${rootProject.rootDir}/libs/novacrypto-bip39")
+
+ /**
+ * Test Dependencies (do not concern the AOSP build)
+ */
+ lintChecks rootProject.ext.lint_libs.exceptions
+
+ // anything less than 'implementation' fails tests run with gradlew
+ testImplementation rootProject.ext.aosp_libs
+ testImplementation 'androidx.test.ext:junit:1.1.2'
+ testImplementation('org.robolectric:robolectric:4.3.1') { // 4.4 has issue with non-idle Looper
+ // https://github.com/robolectric/robolectric/issues/5245
+ exclude group: "com.google.auto.service", module: "auto-service"
+ }
+ testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5_version"
+ testImplementation "io.mockk:mockk:$mockk_version"
+ testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
+ testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit5_version"
+
+ androidTestImplementation 'androidx.test:runner:1.3.0'
+ androidTestImplementation 'androidx.test:rules:1.3.0'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+ androidTestImplementation "io.mockk:mockk-android:$mockk_version"
+}
ktlint {
version = "0.36.0" // https://github.com/pinterest/ktlint/issues/764
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2fc78cf4..7b8504f9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -113,5 +113,18 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index a99e08a6..eafcf5c8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -17,8 +17,10 @@ import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.AppListRetriever
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel
+import com.stevesoltys.seedvault.storage.storageModule
import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule
+import com.stevesoltys.seedvault.ui.files.FileSelectionViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
@@ -43,11 +45,12 @@ open class App : Application() {
factory { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
factory { AppListRetriever(this@App, get(), get(), get()) }
- viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) }
+ viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
+ viewModel { FileSelectionViewModel(this@App, get()) }
}
override fun onCreate() {
@@ -85,6 +88,7 @@ open class App : Application() {
backupModule,
restoreModule,
installModule,
+ storageModule,
appModule
)
)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
index 2f8538cb..ec56126d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
@@ -36,7 +36,7 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
setHasOptionsMenu(true)
val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
index 7e469ada..a339bc51 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
@@ -1,12 +1,11 @@
package com.stevesoltys.seedvault.settings
import android.app.backup.IBackupManager
-import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
import android.content.Intent
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
-import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE // ktlint-disable no-unused-imports
+import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
@@ -38,6 +37,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference
+ private lateinit var backupStorage: TwoStatePreference
+ private lateinit var backupRecoveryCode: Preference
private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null
@@ -102,6 +103,19 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@OnPreferenceChangeListener false
}
backupStatus = findPreference("backup_status")!!
+
+ backupStorage = findPreference("backup_storage")!!
+ backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
+ val disable = !(newValue as Boolean)
+ if (disable) {
+ viewModel.disableStorageBackup()
+ return@OnPreferenceChangeListener true
+ }
+ onEnablingStorageBackup()
+ return@OnPreferenceChangeListener false
+ }
+
+ backupRecoveryCode = findPreference("backup_recovery_code")!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -110,6 +124,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel.lastBackupTime.observe(viewLifecycleOwner, Observer { time ->
setAppBackupStatusSummary(time)
})
+
+ val backupFiles: Preference = findPreference("backup_files")!!
+ viewModel.filesSummary.observe(viewLifecycleOwner, Observer { summary ->
+ backupFiles.summary = summary
+ })
}
override fun onStart() {
@@ -190,4 +209,40 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
}
+ private fun onEnablingStorageBackup() {
+ AlertDialog.Builder(requireContext())
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.settings_backup_storage_dialog_title)
+ .setMessage(R.string.settings_backup_storage_dialog_message)
+ .setPositiveButton(R.string.settings_backup_storage_dialog_ok) { dialog, _ ->
+ if (viewModel.hasMainKey()) {
+ viewModel.enableStorageBackup()
+ backupStorage.isChecked = true
+ } else {
+ showCodeVerificationNeededDialog()
+ }
+ dialog.dismiss()
+ }
+ .setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
+ dialog.dismiss()
+ }
+ .show()
+ }
+
+ private fun showCodeVerificationNeededDialog() {
+ AlertDialog.Builder(requireContext())
+ .setIcon(R.drawable.ic_vpn_key)
+ .setTitle(R.string.settings_backup_storage_code_dialog_title)
+ .setMessage(R.string.settings_backup_storage_code_dialog_message)
+ .setPositiveButton(R.string.settings_backup_storage_code_dialog_ok) { dialog, _ ->
+ val callback = (requireActivity() as OnPreferenceStartFragmentCallback)
+ callback.onPreferenceStartFragment(this, backupRecoveryCode)
+ dialog.dismiss()
+ }
+ .setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
+ dialog.dismiss()
+ }
+ .show()
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
index 10a45232..c84a6664 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
@@ -1,6 +1,7 @@
package com.stevesoltys.seedvault.settings
import android.app.Application
+import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED
import android.database.ContentObserver
import android.net.ConnectivityManager
import android.net.Network
@@ -22,11 +23,15 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.permitDiskReads
+import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.backup.BackupJobService
+import java.util.concurrent.TimeUnit.HOURS
private const val TAG = "SettingsViewModel"
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"
@@ -37,7 +42,8 @@ internal class SettingsViewModel(
keyManager: KeyManager,
private val notificationManager: BackupNotificationManager,
private val metadataManager: MetadataManager,
- private val appListRetriever: AppListRetriever
+ private val appListRetriever: AppListRetriever,
+ private val storageBackup: StorageBackup
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
private val contentResolver = app.contentResolver
@@ -59,6 +65,9 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData()
internal val appEditMode: LiveData = mAppEditMode
+ private val _filesSummary = MutableLiveData()
+ internal val filesSummary: LiveData = _filesSummary
+
private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) {
onStorageLocationChanged()
@@ -89,6 +98,7 @@ internal class SettingsViewModel(
metadataManager.getLastBackupTime()
}
onStorageLocationChanged()
+ loadFilesSummary()
}
override fun onStorageLocationChanged() {
@@ -134,8 +144,8 @@ internal class SettingsViewModel(
// maybe replace the check below with one that checks if our transport service is running
if (notificationManager.hasActiveBackupNotifications()) {
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
- } else {
- Thread { requestBackup(app) }.start()
+ } else viewModelScope.launch(Dispatchers.IO) {
+ requestBackup(app)
}
}
@@ -156,6 +166,14 @@ internal class SettingsViewModel(
settingsManager.onAppBackupStatusChanged(status)
}
+ @UiThread
+ fun loadFilesSummary() = viewModelScope.launch {
+ val uriSummary = storageBackup.getUriSummaryString()
+ _filesSummary.value = if (uriSummary.isEmpty()) {
+ app.getString(R.string.settings_backup_files_summary)
+ } else uriSummary
+ }
+
/**
* Ensures that the call log will be included in backups.
*
@@ -170,4 +188,21 @@ internal class SettingsViewModel(
}
}
+ fun hasMainKey(): Boolean {
+ return keyManager.hasMainKey()
+ }
+
+ fun enableStorageBackup() = BackupJobService.scheduleJob(
+ context = app,
+ jobServiceClass = StorageBackupJobService::class.java,
+ periodMillis = HOURS.toMillis(24),
+ networkType = NETWORK_TYPE_UNMETERED,
+ deviceIdle = false,
+ charging = true
+ )
+
+ fun disableStorageBackup() {
+ BackupJobService.cancelJob(app)
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt
new file mode 100644
index 00000000..9f67ca93
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt
@@ -0,0 +1,20 @@
+package com.stevesoltys.seedvault.storage
+
+import android.content.Context
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.seedvault.crypto.KeyManager
+import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
+import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
+import javax.crypto.SecretKey
+
+internal class SeedvaultStoragePlugin(
+ context: Context,
+ private val storage: DocumentsStorage,
+ private val keyManager: KeyManager
+) : SafStoragePlugin(context) {
+ override val root: DocumentFile
+ get() = storage.rootBackupDir ?: error("No storage set")
+
+ override fun getMasterKey(): SecretKey = keyManager.getMainKey()
+ override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
new file mode 100644
index 00000000..30b82e28
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
@@ -0,0 +1,30 @@
+package com.stevesoltys.seedvault.storage
+
+import org.calyxos.backup.storage.api.BackupObserver
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.backup.BackupJobService
+import org.calyxos.backup.storage.backup.BackupService
+import org.calyxos.backup.storage.backup.NotificationBackupObserver
+import org.koin.android.ext.android.inject
+
+/*
+test and debug with
+
+ adb shell dumpsys jobscheduler |
+ grep -A 23 -B 4 "Service: com.stevesoltys.seedvault/.storage.StorageBackupJobService"
+
+force running with:
+
+ adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0
+
+ */
+internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java)
+
+internal class StorageBackupService : BackupService() {
+ override val storageBackup: StorageBackup by inject()
+
+ // use lazy delegate because context isn't available during construction time
+ override val backupObserver: BackupObserver by lazy {
+ NotificationBackupObserver(applicationContext)
+ }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt
new file mode 100644
index 00000000..68254e88
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt
@@ -0,0 +1,10 @@
+package com.stevesoltys.seedvault.storage
+
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.api.StoragePlugin
+import org.koin.dsl.module
+
+val storageModule = module {
+ single { SeedvaultStoragePlugin(get(), get(), get()) }
+ single { StorageBackup(get(), get()) }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
index 50952758..68b02eec 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
@@ -9,7 +9,7 @@ import com.stevesoltys.seedvault.ui.storage.StorageViewModel
abstract class RequireProvisioningViewModel(
protected val app: Application,
protected val settingsManager: SettingsManager,
- private val keyManager: KeyManager
+ protected val keyManager: KeyManager
) : AndroidViewModel(app) {
abstract val isRestoreOperation: Boolean
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionFragment.kt
new file mode 100644
index 00000000..54099150
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionFragment.kt
@@ -0,0 +1,32 @@
+package com.stevesoltys.seedvault.ui.files
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.stevesoltys.seedvault.R
+import com.stevesoltys.seedvault.settings.SettingsViewModel
+import org.calyxos.backup.storage.ui.backup.BackupContentFragment
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class FileSelectionFragment() : BackupContentFragment() {
+
+ override val viewModel by viewModel()
+ private val settingsViewModel by sharedViewModel()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ requireActivity().setTitle(R.string.settings_backup_files_title)
+ return super.onCreateView(inflater, container, savedInstanceState)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // reload files summary when we navigate away (it might have changed)
+ settingsViewModel.loadFilesSummary()
+ }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionViewModel.kt
new file mode 100644
index 00000000..359c9965
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/files/FileSelectionViewModel.kt
@@ -0,0 +1,18 @@
+package com.stevesoltys.seedvault.ui.files
+
+import android.app.Application
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.ui.backup.BackupContentViewModel
+
+class FileSelectionViewModel(
+ app: Application,
+ override val storageBackup: StorageBackup
+) : BackupContentViewModel(app) {
+
+ init {
+ viewModelScope.launch { loadContent() }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
index 94f0b3c3..1bde12c7 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
@@ -62,7 +62,7 @@ class RecoveryCodeInputFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
introText = v.findViewById(R.id.introText)
@@ -93,6 +93,8 @@ class RecoveryCodeInputFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ activity?.setTitle(R.string.recovery_code_title)
+
if (viewModel.isRestore) {
introText.setText(R.string.recovery_code_input_intro)
backView.visibility = VISIBLE
diff --git a/app/src/main/res/drawable/ic_library_add.xml b/app/src/main/res/drawable/ic_library_add.xml
new file mode 100644
index 00000000..35674009
--- /dev/null
+++ b/app/src/main/res/drawable/ic_library_add.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_save_alt.xml b/app/src/main/res/drawable/ic_save_alt.xml
new file mode 100644
index 00000000..c9407604
--- /dev/null
+++ b/app/src/main/res/drawable/ic_save_alt.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_vpn_key.xml b/app/src/main/res/drawable/ic_vpn_key.xml
index 7b554c94..1374f65f 100644
--- a/app/src/main/res/drawable/ic_vpn_key.xml
+++ b/app/src/main/res/drawable/ic_vpn_key.xml
@@ -1,7 +1,7 @@
Restore backup
- Backup my data
+ App backup
+ Backup my apps
Backup location
None
Internal storage
@@ -28,10 +29,20 @@
Last backup: %1$s
Exclude apps
Backup now
+ Storage backup (experimental)
+ Backup my files
+ Included files and folders
+ None
Recovery code
Verify existing code or generate a new one
+ Experimental feature
+ Backing up files is still experimental and might not work. Do not rely on it for important data.
+ Enable anyway
+ Recovery code verification required
+ To enable storage backup, you need to first verify your recovery code or generate a new one.
+ Verify code
-
+
Choose where to store backups
Where to find your backups?
People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.
diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml
index a9954c06..e9034e67 100644
--- a/app/src/main/res/xml/settings.xml
+++ b/app/src/main/res/xml/settings.xml
@@ -1,53 +1,75 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
index 49a182e4..b7346b2c 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
@@ -1,11 +1,13 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
+import android.content.pm.PackageManager
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp
+import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
@@ -27,6 +29,14 @@ internal class DocumentFileTest {
"content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup"
)
+
+ init {
+ // needed since 'androidx.documentfile:documentfile:1.0.1'
+ val pm: PackageManager = mockk()
+ every { context.packageManager } returns pm
+ every { pm.queryIntentContentProviders(any(), 0) } returns emptyList()
+ }
+
private val parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!!
private val uri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" +
diff --git a/build.gradle b/build.gradle
index 98c5c515..436db1f8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,10 +1,10 @@
buildscript {
-
// 1.3.21 Android 10
// 1.3.61 Android 11
// Check:
// https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-11.0.0_r3/build.txt
- ext.kotlin_version = '1.3.61'
+ ext.aosp_kotlin_version = '1.3.61'
+ ext.kotlin_version = '1.4.31'
repositories {
jcenter()
@@ -13,10 +13,20 @@ buildscript {
dependencies {
//noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath 'com.android.tools.build:gradle:4.1.0'
+ classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.14"
+ classpath 'com.android.tools.build:gradle:4.1.2'
}
}
+ext {
+ buildToolsVersion = '30.0.2'
+ compileSdkVersion = 30
+ minSdkVersion = 29
+ targetSdkVersion = 30
+}
+
+apply from: 'gradle/dependencies.gradle'
+
allprojects {
repositories {
mavenCentral()
diff --git a/contactsbackup/build.gradle b/contactsbackup/build.gradle
index 9fcec7c5..8fb5026d 100644
--- a/contactsbackup/build.gradle
+++ b/contactsbackup/build.gradle
@@ -50,16 +50,12 @@ def aospDeps = fileTree(include: [
dependencies {
implementation aospDeps
- //noinspection GradleDependency
testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
- testImplementation 'junit:junit:4.13.1'
- def mockk_version = "1.10.2"
+ testImplementation "junit:junit:$junit4_version"
testImplementation "io.mockk:mockk:$mockk_version"
- //noinspection GradleDependency
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
- def espresso_version = "3.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
}
diff --git a/gradle.properties b/gradle.properties
index f3edb44c..38138b6a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,5 @@
org.gradle.jvmargs=-Xmx1g
+org.gradle.configureondemand=true
android.useAndroidX=true
android.enableJetifier=false
+kotlin.code.style=official
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index 103beeba..752c2d24 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -1,130 +1,101 @@
+ext {
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2713
+ ext.room_version = "2.3.0-alpha02"
+ // http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
+ ext.protobuf_version = "3.9.1"
+ junit4_version = "4.13.1"
+ junit5_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
+ mockk_version = "1.10.2"
+ espresso_version = "3.3.0"
+}
+
// To produce these binaries, in latest AOSP source tree, run
// $ m
-def aospDeps = fileTree(include: [
- // For more information about this module:
- // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
- // framework_intermediates/classes-header.jar works for gradle build as well,
- // but not unit tests, so we use the actual classes (without updatable modules).
- //
- // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
- 'android.jar',
- // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
- 'libcore.jar'
-], dir: 'libs')
+ext.aosp_libs = fileTree(include: [
+ // For more information about this module:
+ // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
+ // framework_intermediates/classes-header.jar works for gradle build as well,
+ // but not unit tests, so we use the actual classes (without updatable modules).
+ //
+ // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
+ 'android.jar',
+ // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
+ 'libcore.jar',
+], dir: "$projectDir/app/libs")
-dependencies {
- compileOnly aospDeps
+ext.kotlin_libs = [
+ std: [
+ dependencies.create('org.jetbrains.kotlin:kotlin-stdlib') {
+ version { strictly "$aosp_kotlin_version" }
+ },
+ dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
+ version { strictly "$aosp_kotlin_version" }
+ },
+ dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-common') {
+ version { strictly "$aosp_kotlin_version" }
+ },
+ ],
+ coroutines: [
+ dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
+ // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
+ version { strictly '1.3.0' }
+ },
+ dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
+ // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
+ version { strictly '1.3.0' }
+ },
+ ],
+]
- /**
- * Dependencies in AOSP
- *
- * We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
- * with the AOSP build system and gradle builds are just for more pleasant development.
- * Using the AOSP versions in gradle builds allows us to spot issues early on.
- */
-
- implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
- version { strictly "$kotlin_version" }
- }
- implementation('org.jetbrains.kotlin:kotlin-stdlib-common') {
- version { strictly "$kotlin_version" }
- }
- implementation('org.jetbrains.kotlin:kotlin-stdlib') {
- version { strictly "$kotlin_version" }
- }
-
- // These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
- implementation('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
- // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
- version { strictly '1.3.0' }
- }
- implementation('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
- // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
- version { strictly '1.3.0' }
- }
-
- implementation('androidx.core:core-ktx') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#610
+ext.std_libs = [
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#610
+ androidx_core: dependencies.create('androidx.core:core-ktx') {
version { strictly '1.5.0-alpha01' }
- }
-
- // A newer version gets pulled in with AOSP via core, so we include this here explicitly
- implementation('androidx.fragment:fragment-ktx') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#930
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#930
+ androidx_fragment: dependencies.create('androidx.fragment:fragment-ktx') {
version { strictly '1.3.0-alpha07' }
- }
-
- implementation('androidx.preference:preference') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2412
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2412
+ androidx_preference: dependencies.create('androidx.preference:preference') {
version { strictly '1.1.1' } // should be 1.2.0-alpha01, but that is not even released, yet
- }
-
- implementation('androidx.lifecycle:lifecycle-viewmodel-ktx') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1553
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1553
+ androidx_lifecycle_viewmodel_ktx: dependencies.create('androidx.lifecycle:lifecycle-viewmodel-ktx') {
version { strictly '2.3.0-alpha05' }
- }
-
- implementation('androidx.lifecycle:lifecycle-livedata-ktx') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1353
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1353
+ androidx_lifecycle_livedata_ktx: dependencies.create('androidx.lifecycle:lifecycle-livedata-ktx') {
version { strictly '2.3.0-alpha05' }
- }
-
- implementation('androidx.constraintlayout:constraintlayout') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/constraint-layout-x/Android.bp#30
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/constraint-layout-x/Android.bp#30
+ androidx_constraintlayout: dependencies.create('androidx.constraintlayout:constraintlayout') {
version { strictly '2.0.0-beta7' }
- }
-
- implementation('com.google.android.material:material') {
- // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/material-design-x/Android.bp#6
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#708
+ androidx_documentfile: dependencies.create('androidx.documentfile:documentfile') {
+ version { strictly '1.0.1' } // should be 1.1.0-alpha01, but that is not even released, yet
+ },
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/material-design-x/Android.bp#6
+ com_google_android_material: dependencies.create('com.google.android.material:material') {
version { strictly '1.1.0-alpha05' }
- }
+ },
+]
+ext.lint_libs = [
+ exceptions: 'com.github.thirdegg:lint-rules:0.0.6-beta'
+]
- /**
- * External Dependencies
- *
- * If the dependencies below are updated,
- * please make sure to update the prebuilt libraries and the Android.bp files
- * in the top-level `libs` folder to reflect that.
- * You can copy these libraries from ~/.gradle/caches/modules-2
- */
-
- def koin_version = '2.1.1' // later versions require newer kotlin version
- //noinspection GradleDependency
- implementation("org.koin:koin-android:$koin_version") {
- exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
- }
- //noinspection GradleDependency
- implementation("org.koin:koin-androidx-viewmodel:$koin_version") {
- exclude group: 'org.koin', module: 'koin-androidx-scope'
- exclude group: 'androidx.lifecycle'
- }
-
- implementation('io.github.novacrypto:BIP39:2019.01.27') {
- exclude group: 'com.madgag.spongycastle'
- }
-
- /**
- * Test Dependencies (do not concern the AOSP build)
- */
-
- lintChecks 'com.github.thirdegg:lint-rules:0.0.5-alpha'
-
- def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
- def mockk_version = "1.10.0"
- testImplementation aospDeps // anything less than 'implementation' fails tests run with gradlew
- testImplementation 'androidx.test.ext:junit:1.1.2'
- testImplementation('org.robolectric:robolectric:4.3.1') { // 4.4 has issue with non-idle Looper
- // https://github.com/robolectric/robolectric/issues/5245
- exclude group: "com.google.auto.service", module: "auto-service"
- }
- testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
- testImplementation "io.mockk:mockk:$mockk_version"
- testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
- testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
-
- androidTestImplementation 'androidx.test:runner:1.3.0'
- androidTestImplementation 'androidx.test:rules:1.3.0'
- androidTestImplementation 'androidx.test.ext:junit:1.1.2'
- androidTestImplementation "io.mockk:mockk-android:$mockk_version"
-}
+ext.storage_libs = [
+ // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2711
+ androidx_room_runtime: dependencies.create('androidx.room:room-runtime') {
+ version { strictly "$room_version" }
+ },
+ // http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
+ com_google_protobuf_javalite: dependencies.create('com.google.protobuf:protobuf-javalite') {
+ version { strictly "$protobuf_version" }
+ },
+ com_google_crypto_tink_android: dependencies.create('com.google.crypto.tink:tink-android') {
+ version { strictly '1.5.0' }
+ },
+]
diff --git a/permissions_com.stevesoltys.seedvault.xml b/permissions_com.stevesoltys.seedvault.xml
index da36b245..62051bc3 100644
--- a/permissions_com.stevesoltys.seedvault.xml
+++ b/permissions_com.stevesoltys.seedvault.xml
@@ -5,5 +5,6 @@
+
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 717326dd..87893e08 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,4 @@
include ':app'
include ':contactsbackup'
+include ':storage:lib'
+include ':storage:demo'
\ No newline at end of file
diff --git a/storage/.gitignore b/storage/.gitignore
new file mode 100644
index 00000000..2481f867
--- /dev/null
+++ b/storage/.gitignore
@@ -0,0 +1 @@
+release.sh
diff --git a/storage/demo/.gitignore b/storage/demo/.gitignore
new file mode 100644
index 00000000..4e4023c8
--- /dev/null
+++ b/storage/demo/.gitignore
@@ -0,0 +1,2 @@
+/build
+/debug
diff --git a/storage/demo/build.gradle b/storage/demo/build.gradle
new file mode 100644
index 00000000..3ecd0fce
--- /dev/null
+++ b/storage/demo/build.gradle
@@ -0,0 +1,57 @@
+plugins {
+ id 'com.android.application'
+ id 'com.google.protobuf'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+}
+
+android {
+ compileSdkVersion rootProject.ext.compileSdkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
+
+ defaultConfig {
+ applicationId "de.grobox.storagebackuptester"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 18
+ versionName "0.9.5"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments disableAnalytics: 'true'
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
+ }
+ packagingOptions {
+ exclude 'META-INF/*.kotlin_module'
+ exclude 'META-INF/androidx.*.version'
+ exclude 'META-INF/services/kotlin*'
+ exclude 'kotlin/internal/internal.kotlin_builtins'
+ }
+}
+
+dependencies {
+ implementation project(':storage:lib')
+
+ implementation rootProject.ext.std_libs.androidx_core
+ // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
+ implementation rootProject.ext.std_libs.androidx_fragment
+ implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
+ implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
+ implementation rootProject.ext.std_libs.androidx_constraintlayout
+ implementation rootProject.ext.std_libs.com_google_android_material
+
+ implementation rootProject.ext.storage_libs.com_google_protobuf_javalite
+}
diff --git a/storage/demo/proguard-rules.pro b/storage/demo/proguard-rules.pro
new file mode 100644
index 00000000..55835b34
--- /dev/null
+++ b/storage/demo/proguard-rules.pro
@@ -0,0 +1,23 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+-dontobfuscate
diff --git a/storage/demo/src/main/AndroidManifest.xml b/storage/demo/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..6846fa36
--- /dev/null
+++ b/storage/demo/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/storage/demo/src/main/assets/test.jpg b/storage/demo/src/main/assets/test.jpg
new file mode 100644
index 00000000..06554645
Binary files /dev/null and b/storage/demo/src/main/assets/test.jpg differ
diff --git a/storage/demo/src/main/ic_launcher-playstore.png b/storage/demo/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..927828be
Binary files /dev/null and b/storage/demo/src/main/ic_launcher-playstore.png differ
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt
new file mode 100644
index 00000000..d624041d
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt
@@ -0,0 +1,40 @@
+package de.grobox.storagebackuptester
+
+import android.app.Application
+import android.os.StrictMode
+import android.os.StrictMode.VmPolicy
+import android.util.Log
+import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin
+import de.grobox.storagebackuptester.settings.SettingsManager
+import org.calyxos.backup.storage.api.StorageBackup
+
+class App : Application() {
+
+ val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
+ val storageBackup: StorageBackup by lazy {
+ val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
+ StorageBackup(this, plugin)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ StrictMode.setThreadPolicy(
+ StrictMode.ThreadPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build()
+ )
+ StrictMode.setVmPolicy(
+ VmPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build()
+ )
+ }
+
+ override fun onLowMemory() {
+ super.onLowMemory()
+ Log.e("TEST", "ON LOW MEMORY!!!")
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/Job.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/Job.kt
new file mode 100644
index 00000000..627f6e5a
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/Job.kt
@@ -0,0 +1,46 @@
+package de.grobox.storagebackuptester
+
+import android.content.Context
+import org.calyxos.backup.storage.api.BackupObserver
+import org.calyxos.backup.storage.api.RestoreObserver
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.backup.BackupJobService
+import org.calyxos.backup.storage.backup.BackupService
+import org.calyxos.backup.storage.backup.NotificationBackupObserver
+import org.calyxos.backup.storage.restore.NotificationRestoreObserver
+import org.calyxos.backup.storage.restore.RestoreService
+import java.util.concurrent.TimeUnit.HOURS
+
+// debug with:
+// adb shell dumpsys jobscheduler | grep -B 4 -A 24 "Service: de.grobox.storagebackuptester/.DemoBackupJobService"
+
+class DemoBackupJobService : BackupJobService(DemoBackupService::class.java) {
+ companion object {
+ fun scheduleJob(context: Context) {
+ scheduleJob(
+ context = context,
+ jobServiceClass = DemoBackupJobService::class.java,
+// periodMillis = JobInfo.getMinPeriodMillis(), // for testing
+ periodMillis = HOURS.toMillis(12), // less than 15min won't work
+ deviceIdle = false,
+ charging = false,
+ )
+ }
+ }
+}
+
+class DemoBackupService : BackupService() {
+ // use lazy delegate because context isn't available during construction time
+ override val storageBackup: StorageBackup by lazy { (application as App).storageBackup }
+ override val backupObserver: BackupObserver by lazy {
+ NotificationBackupObserver(applicationContext)
+ }
+}
+
+class DemoRestoreService : RestoreService() {
+ // use lazy delegate because context isn't available during construction time
+ override val storageBackup: StorageBackup by lazy { (application as App).storageBackup }
+ override val restoreObserver: RestoreObserver by lazy {
+ NotificationRestoreObserver(applicationContext)
+ }
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/LogAdapter.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/LogAdapter.kt
new file mode 100644
index 00000000..70bc59c8
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/LogAdapter.kt
@@ -0,0 +1,43 @@
+package de.grobox.storagebackuptester
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+
+class LogAdapter : RecyclerView.Adapter() {
+
+ val items: ArrayList = ArrayList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val v = LayoutInflater.from(parent.context).inflate(R.layout.item_log, parent, false)
+ return ViewHolder(v)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = items[position]
+ holder.bind(item)
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ fun addItem(item: String) {
+ items.add(item)
+ notifyItemInserted(items.size - 1)
+ }
+
+ fun clear() {
+ items.clear()
+ notifyDataSetChanged()
+ }
+
+ class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val logView: TextView = view.findViewById(R.id.logView)
+
+ fun bind(item: String) {
+ logView.text = item
+ }
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/LogFragment.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/LogFragment.kt
new file mode 100644
index 00000000..d60c220d
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/LogFragment.kt
@@ -0,0 +1,142 @@
+package de.grobox.storagebackuptester
+
+import android.Manifest.permission.ACCESS_MEDIA_LOCATION
+import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+import android.content.Intent
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+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 android.widget.Toast
+import android.widget.Toast.LENGTH_SHORT
+import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.recyclerview.widget.RecyclerView
+import de.grobox.storagebackuptester.settings.SettingsFragment
+
+private const val EMAIL = "incoming+grote-storage-backup-tester-22079635-issue-@incoming.gitlab.com"
+
+open class LogFragment : Fragment() {
+
+ companion object {
+ fun newInstance(): LogFragment = LogFragment()
+ }
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ private lateinit var list: RecyclerView
+ private lateinit var progressBar: ProgressBar
+ private lateinit var horizontalProgressBar: ProgressBar
+ private lateinit var button: Button
+ private val adapter = LogAdapter()
+
+ private val permissionRequest =
+ registerForActivityResult(RequestMultiplePermissions()) { grantedMap ->
+ if (grantedMap[WRITE_EXTERNAL_STORAGE] == true) {
+ Toast.makeText(requireContext(), "Please try again now!", LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ setHasOptionsMenu(true)
+ val v = inflater.inflate(R.layout.fragment_log, container, false)
+ list = v.findViewById(R.id.listView)
+ list.adapter = adapter
+ progressBar = v.findViewById(R.id.progressBar)
+ horizontalProgressBar = v.findViewById(R.id.horizontalProgressBar)
+ button = v.findViewById(R.id.button)
+ viewModel.backupLog.observe(viewLifecycleOwner, { progress ->
+ progress.text?.let { adapter.addItem(it) }
+ horizontalProgressBar.max = progress.total
+ horizontalProgressBar.setProgress(progress.current, true)
+ list.postDelayed({
+ list.scrollToPosition(adapter.itemCount - 1)
+ }, 50)
+ })
+ viewModel.backupButtonEnabled.observe(viewLifecycleOwner, { enabled ->
+ button.isEnabled = enabled
+ progressBar.visibility = if (enabled) INVISIBLE else VISIBLE
+ if (!enabled) adapter.clear()
+ })
+ button.setOnClickListener {
+ if (!checkPermission()) return@setOnClickListener
+ viewModel.simulateBackup()
+ }
+ return v
+ }
+
+ override fun onStart() {
+ super.onStart()
+ requireActivity().setTitle(R.string.app_name)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ inflater.inflate(R.menu.fragment_main, menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.settings -> {
+ if (!checkPermission()) return false
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.container, SettingsFragment.newInstance())
+ .addToBackStack("SETTINGS")
+ .commit()
+ true
+ }
+ R.id.share -> {
+ val subject = adapter.items.takeLast(2).joinToString(" - ").replace("\n", "")
+ val text = adapter.items.takeLast(333).joinToString("\n")
+ val sendIntent: Intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, text)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ startActivity(shareIntent)
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun checkPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= 30) {
+ if (Environment.isExternalStorageManager()) return true
+ Toast.makeText(requireContext(), "Permission needed", LENGTH_SHORT).show()
+ val i = Intent(ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
+ data = Uri.parse("package:${requireContext().packageName}")
+ }
+ startActivity(i)
+ false
+ } else {
+ if (requireContext().checkSelfPermission(WRITE_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
+ true
+ } else {
+ Toast.makeText(requireContext(), "No storage permission", LENGTH_SHORT).show()
+ permissionRequest.launch(arrayOf(WRITE_EXTERNAL_STORAGE, ACCESS_MEDIA_LOCATION))
+ false
+ }
+ }
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt
new file mode 100644
index 00000000..84e5b669
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt
@@ -0,0 +1,31 @@
+package de.grobox.storagebackuptester
+
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import de.grobox.storagebackuptester.crypto.KeyManager
+
+class MainActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.container, LogFragment.newInstance())
+ .commitNow()
+ }
+
+ KeyManager.storeMasterKey()
+
+ if (!KeyManager.hasMasterKey()) {
+ Log.e("TEST", "storing new key")
+ KeyManager.storeMasterKey()
+ } else {
+ Log.e("TEST", "already have key")
+ }
+
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt
new file mode 100644
index 00000000..f0c23945
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt
@@ -0,0 +1,133 @@
+package de.grobox.storagebackuptester
+
+import android.app.Application
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import de.grobox.storagebackuptester.backup.BackupProgress
+import de.grobox.storagebackuptester.backup.BackupStats
+import de.grobox.storagebackuptester.restore.RestoreProgress
+import de.grobox.storagebackuptester.restore.RestoreStats
+import de.grobox.storagebackuptester.scanner.scanTree
+import de.grobox.storagebackuptester.scanner.scanUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.api.SnapshotResult
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.backup.BackupJobService
+import org.calyxos.backup.storage.scanner.DocumentScanner
+import org.calyxos.backup.storage.scanner.MediaScanner
+import org.calyxos.backup.storage.ui.backup.BackupContentViewModel
+import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
+
+private val logEmptyState = """
+ Press the button below to simulate a backup. Your files won't be changed and not uploaded anywhere. This is just to test code for a future real backup.
+
+ Please come back to this app from time to time and run a backup again to see if it correctly identifies files that were added/changed.
+
+ Note that after updating this app, it might need to re-backup all files again.
+
+ Thanks for testing!
+""".trimIndent()
+private const val TAG = "MainViewModel"
+
+class MainViewModel(application: Application) : BackupContentViewModel(application),
+ SnapshotViewModel {
+
+ private val app: App = application as App
+ private val settingsManager = app.settingsManager
+ override val storageBackup: StorageBackup = app.storageBackup
+
+ private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState))
+ val backupLog: LiveData = _backupLog
+
+ private val _buttonEnabled = MutableLiveData()
+ val backupButtonEnabled: LiveData = _buttonEnabled
+
+ private val _restoreLog = MutableLiveData()
+ val restoreLog: LiveData = _restoreLog
+
+ private val _restoreProgressVisible = MutableLiveData()
+ val restoreProgressVisible: LiveData = _restoreProgressVisible
+
+ override val snapshots: LiveData
+ get() = storageBackup.getBackupSnapshots().asLiveData(Dispatchers.IO)
+
+ init {
+ viewModelScope.launch { loadContent() }
+ }
+
+ fun simulateBackup() {
+ _buttonEnabled.value = false
+ val backupObserver = BackupStats(app, storageBackup, _backupLog)
+ viewModelScope.launch(Dispatchers.IO) {
+ val text = storageBackup.getUriSummaryString()
+ _backupLog.postValue(BackupProgress(0, 0, "Scanning: $text\n"))
+ if (storageBackup.runBackup(backupObserver)) {
+ // only prune old backups when backup run was successful
+ storageBackup.pruneOldBackups(backupObserver)
+ }
+ _buttonEnabled.postValue(true)
+ }
+ }
+
+ suspend fun scanMediaUri(uri: Uri): String = withContext(Dispatchers.Default) {
+ scanUri(app, MediaScanner(app), uri)
+ }
+
+ suspend fun scanDocumentUri(uri: Uri): String = withContext(Dispatchers.Default) {
+ scanTree(app, DocumentScanner(app), uri)
+ }
+
+ fun clearDb() {
+ viewModelScope.launch(Dispatchers.IO) { storageBackup.clearCache() }
+ }
+
+ fun setBackupLocation(uri: Uri?) {
+ if (uri != null) clearDb()
+ settingsManager.setBackupLocation(uri)
+ }
+
+ fun hasBackupLocation(): Boolean {
+ return settingsManager.getBackupLocation() != null
+ }
+
+ fun setAutomaticBackupsEnabled(enabled: Boolean) {
+ if (enabled) DemoBackupJobService.scheduleJob(app)
+ else BackupJobService.cancelJob(app)
+ settingsManager.setAutomaticBackupsEnabled(enabled)
+ }
+
+ fun areAutomaticBackupsEnabled(): Boolean {
+ val enabled = settingsManager.areAutomaticBackupsEnabled()
+ if (enabled && !BackupJobService.isScheduled(app)) {
+ Log.w(TAG, "Automatic backups enabled, but job not scheduled. Scheduling...")
+ DemoBackupJobService.scheduleJob(app)
+ }
+ return enabled
+ }
+
+ fun onSnapshotClicked(item: SnapshotItem) {
+ val snapshot = item.snapshot
+ check(snapshot != null)
+
+ // example for how to do restore via foreground service
+// app.startForegroundService(Intent(app, DemoRestoreService::class.java).apply {
+// putExtra(EXTRA_TIMESTAMP_START, snapshot.timeStart)
+// })
+
+ // example for how to do restore via fragment
+ _restoreProgressVisible.value = true
+ val restoreObserver = RestoreStats(app, _restoreLog)
+ viewModelScope.launch {
+ storageBackup.restoreBackupSnapshot(snapshot, restoreObserver)
+ _restoreProgressVisible.value = false
+ }
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/backup/BackupStats.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/backup/BackupStats.kt
new file mode 100644
index 00000000..31468272
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/backup/BackupStats.kt
@@ -0,0 +1,182 @@
+package de.grobox.storagebackuptester.backup
+
+import android.content.Context
+import android.provider.MediaStore
+import android.text.format.DateUtils.FORMAT_ABBREV_ALL
+import android.text.format.DateUtils.getRelativeTimeSpanString
+import android.text.format.Formatter
+import android.util.Log
+import androidx.lifecycle.MutableLiveData
+import org.calyxos.backup.storage.api.BackupFile
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.backup.NotificationBackupObserver
+import kotlin.time.DurationUnit
+import kotlin.time.ExperimentalTime
+import kotlin.time.toDuration
+
+data class BackupProgress(
+ val current: Int,
+ val total: Int,
+ val text: String?,
+)
+
+internal class BackupStats(
+ private val context: Context,
+ private val storageBackup: StorageBackup,
+ private val liveData: MutableLiveData,
+) : NotificationBackupObserver(context) {
+ private var filesProcessed: Int = 0
+ private var totalFiles: Int = 0
+ private var filesUploaded: Int = 0
+ private var expectedSize: Long = 0L
+ private var size: Long = 0L
+ private var savedChunks: Int = 0
+ private val errorStrings = ArrayList()
+
+ override suspend fun onBackupStart(
+ totalSize: Long,
+ numFiles: Int,
+ numSmallFiles: Int,
+ numLargeFiles: Int
+ ) {
+ super.onBackupStart(totalSize, numFiles, numSmallFiles, numLargeFiles)
+
+ totalFiles = numFiles
+ expectedSize = totalSize
+
+ val totalSizeStr = Formatter.formatShortFileSize(context, totalSize)
+ val text = "Backing up $totalFiles file(s) $totalSizeStr...\n" +
+ " ($numSmallFiles small, $numLargeFiles large)\n"
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
+ }
+
+ override suspend fun onFileBackedUp(
+ file: BackupFile,
+ wasUploaded: Boolean,
+ reusedChunks: Int,
+ bytesWritten: Long,
+ tag: String,
+ ) {
+ super.onFileBackedUp(file, wasUploaded, reusedChunks, bytesWritten, tag)
+
+ filesProcessed++
+ if (!wasUploaded) return
+
+ savedChunks += reusedChunks
+ filesUploaded++
+ size += bytesWritten
+
+ val sizeStr = Formatter.formatShortFileSize(context, file.size)
+ val now = System.currentTimeMillis()
+ val modStr = file.lastModified?.let {
+ getRelativeTimeSpanString(it, now, 0L, FORMAT_ABBREV_ALL)
+ } ?: "NULL"
+
+ val volume =
+ if (file.volume == MediaStore.VOLUME_EXTERNAL_PRIMARY) "" else "v: ${file.volume}"
+ val text = "${file.path}\n s: $sizeStr m: $modStr $volume"
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
+ }
+
+ override suspend fun onFileBackupError(file: BackupFile, tag: String) {
+ super.onFileBackupError(file, tag)
+
+ filesProcessed++
+ errorStrings.add("ERROR $tag: ${file.path}")
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, null))
+ }
+
+ @OptIn(ExperimentalTime::class)
+ override suspend fun onBackupComplete(backupDuration: Long?) {
+ super.onBackupComplete(backupDuration)
+
+ val sb = StringBuilder("\n")
+ errorStrings.forEach { sb.appendLine(it) }
+ sb.appendLine()
+
+ if (savedChunks > 0) sb.appendLine("Chunks re-used: $savedChunks")
+
+ Log.e("TEST", "Total file size: $expectedSize")
+ Log.e("TEST", "Actual size processed: $size")
+
+ val sizeStr = Formatter.formatShortFileSize(context, size)
+ if (backupDuration != null) {
+ val speed = getSpeed(size, backupDuration / 1000)
+
+ val duration = backupDuration.toDuration(DurationUnit.MILLISECONDS)
+ val perFile = if (filesUploaded > 0) duration.div(filesUploaded) else duration
+
+ sb.appendLine("New/changed files backed up: $filesUploaded ($sizeStr)")
+ if (filesUploaded > 0) sb.append(" ($perFile per file - ${speed}MB/sec)")
+ }
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, sb.toString()))
+ }
+
+ override suspend fun onPruneStart(snapshotsToDelete: List) {
+ super.onPruneStart(snapshotsToDelete)
+
+ filesProcessed = 0
+ totalFiles = snapshotsToDelete.size
+ size = 0L
+ val r = storageBackup.getSnapshotRetention()
+
+ if (totalFiles > 0) {
+ val text = """
+ Pruning $totalFiles old backup(s) from storage...
+ Retaining snapshots:
+ - ${r.daily} daily
+ - ${r.weekly} weekly
+ - ${r.monthly} monthly
+ - ${r.yearly} yearly
+ """.trimIndent()
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
+ }
+ }
+
+ override suspend fun onPruneSnapshot(snapshot: Long, numChunksToDelete: Int, size: Long) {
+ super.onPruneSnapshot(snapshot, numChunksToDelete, size)
+
+ filesProcessed++
+ this.size += size
+ val now = System.currentTimeMillis()
+ val time = getRelativeTimeSpanString(snapshot, now, 0L, FORMAT_ABBREV_ALL)
+ val sizeStr = Formatter.formatShortFileSize(context, size)
+ val text = "Pruning snapshot from $time\n deleting $numChunksToDelete chunks ($sizeStr)..."
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
+ }
+
+ override suspend fun onPruneError(snapshot: Long?, e: Exception) {
+ super.onPruneError(snapshot, e)
+
+ val time = if (snapshot != null) {
+ filesProcessed++
+ val now = System.currentTimeMillis()
+ getRelativeTimeSpanString(snapshot, now, 0L, FORMAT_ABBREV_ALL)
+ } else "null"
+ val text = "ERROR $snapshot $time\n e: ${e.message}"
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
+ }
+
+ @OptIn(ExperimentalTime::class)
+ override suspend fun onPruneComplete(pruneDuration: Long) {
+ super.onPruneComplete(pruneDuration)
+
+ val sb = StringBuilder("\n")
+
+ val sizeStr = Formatter.formatShortFileSize(context, size)
+
+ val duration = pruneDuration.toDuration(DurationUnit.MILLISECONDS)
+ val perSnapshot = if (filesProcessed > 0) duration.div(filesProcessed) else duration
+
+ sb.appendLine("Deleting $filesProcessed old backup snapshot(s) took $duration.")
+ if (filesProcessed > 0) sb.append(" (freed $sizeStr - took $perSnapshot per snapshot)")
+ liveData.postValue(BackupProgress(filesProcessed, totalFiles, sb.toString()))
+ }
+
+}
+
+fun getSpeed(size: Long, duration: Long): Long {
+ val mb = size / 1024 / 1024
+ return if (duration == 0L) (mb.toDouble() / 0.01).toLong()
+ else mb / duration
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt
new file mode 100644
index 00000000..22b9a9b2
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt
@@ -0,0 +1,53 @@
+package de.grobox.storagebackuptester.crypto
+
+import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
+import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
+import android.security.keystore.KeyProperties.PURPOSE_SIGN
+import android.security.keystore.KeyProperties.PURPOSE_VERIFY
+import android.security.keystore.KeyProtection
+import java.security.KeyStore
+import javax.crypto.SecretKey
+import javax.crypto.spec.SecretKeySpec
+
+object KeyManager {
+
+ private const val KEY_SIZE = 256
+ internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
+ private const val KEY_ALIAS_MASTER = "com.stevesoltys.seedvault.master"
+ private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+ private const val ALGORITHM_HMAC = "HmacSHA256"
+
+ private const val FAKE_SEED = "This is a legacy backup key 1234"
+
+ private val keyStore by lazy {
+ KeyStore.getInstance(ANDROID_KEY_STORE).apply {
+ load(null)
+ }
+ }
+
+ fun storeMasterKey() {
+ val seed = FAKE_SEED.toByteArray()
+ storeMasterKey(seed)
+ }
+
+ private fun storeMasterKey(seed: ByteArray) {
+ if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
+ val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, ALGORITHM_HMAC)
+ val ksEntry = KeyStore.SecretKeyEntry(secretKeySpec)
+ keyStore.setEntry(KEY_ALIAS_MASTER, ksEntry, getKeyProtection())
+ }
+
+ fun hasMasterKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER)
+
+ fun getMasterKey(): SecretKey {
+ val ksEntry = keyStore.getEntry(KEY_ALIAS_MASTER, null) as KeyStore.SecretKeyEntry
+ return ksEntry.secretKey
+ }
+
+ private fun getKeyProtection(): KeyProtection {
+ val builder =
+ KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT or PURPOSE_SIGN or PURPOSE_VERIFY)
+ return builder.build()
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt
new file mode 100644
index 00000000..286f13d5
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt
@@ -0,0 +1,49 @@
+package de.grobox.storagebackuptester.plugin
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import de.grobox.storagebackuptester.crypto.KeyManager
+import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
+import java.io.IOException
+import java.io.OutputStream
+import javax.crypto.SecretKey
+
+@Suppress("BlockingMethodInNonBlockingContext")
+class TestSafStoragePlugin(
+ private val context: Context,
+ private val getLocationUri: () -> Uri?,
+) : SafStoragePlugin(context) {
+
+ override val root: DocumentFile?
+ get() {
+ val uri = getLocationUri() ?: return null
+ return DocumentFile.fromTreeUri(context, uri) ?: error("No doc file from tree Uri")
+ }
+
+ private val nullStream = object : OutputStream() {
+ override fun write(b: Int) {
+ // oops
+ }
+ }
+
+ override fun getMasterKey(): SecretKey {
+ return KeyManager.getMasterKey()
+ }
+
+ override fun hasMasterKey(): Boolean {
+ return KeyManager.hasMasterKey()
+ }
+
+ @Throws(IOException::class)
+ override fun getChunkOutputStream(chunkId: String): OutputStream {
+ if (getLocationUri() == null) return nullStream
+ return super.getChunkOutputStream(chunkId)
+ }
+
+ override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
+ if (root == null) return nullStream
+ return super.getBackupSnapshotOutputStream(timestamp)
+ }
+
+}
diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoSnapshotFragment.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoSnapshotFragment.kt
new file mode 100644
index 00000000..66990226
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoSnapshotFragment.kt
@@ -0,0 +1,42 @@
+package de.grobox.storagebackuptester.restore
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewStub
+import android.widget.Button
+import androidx.fragment.app.activityViewModels
+import de.grobox.storagebackuptester.MainViewModel
+import de.grobox.storagebackuptester.R
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.ui.restore.SnapshotFragment
+
+class DemoSnapshotFragment : SnapshotFragment() {
+
+ override val viewModel: MainViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val v = super.onCreateView(inflater, container, savedInstanceState)
+ val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
+ bottomStub.layoutResource = R.layout.footer_snapshot
+ val footer = bottomStub.inflate()
+ footer.findViewById