diff --git a/.editorconfig b/.editorconfig
index d2aef5ea..9b1c6c58 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,4 +1,24 @@
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
[*.{kt,kts}]
-indent_size=4
-insert_final_newline=true
-max_line_length=100
+indent_size = 4
+max_line_length = 100
+
+[app/src/main/res/values-*/strings.xml]
+insert_final_newline = unset
+trim_trailing_whitespace = unset
+
+[*.md]
+trim_trailing_whitespace = false
+
+[gradlew.bat]
+charset = latin1
+end_of_line = crlf
+insert_final_newline = false
+
+[.editorconfig]
+ij_editorconfig_spaces_around_assignment_operators = true
diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml
index 40fa9fc4..be19d3b7 100644
--- a/.github/workflows/client.yml
+++ b/.github/workflows/client.yml
@@ -32,4 +32,4 @@ jobs:
java-version: 11
- name: Build
- run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
\ No newline at end of file
+ run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
diff --git a/.gitignore b/.gitignore
index ec605697..5a81439f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,11 +6,12 @@ hs_err_pid*
## Intellij
out/
-lib/
+/lib/
.idea/*
!.idea/runConfigurations*
!.idea/inspectionProfiles*
!.idea/codeStyles*
+!.idea/dictionaries*
*.ipr
*.iws
*.iml
@@ -33,7 +34,8 @@ local.properties
## NetBeans
**/nbproject/private/
-build/
+/build/
+/app/build/
nbbuild/
dist/
nbdist/
@@ -50,6 +52,3 @@ gradle-app.setting
## Android
gen/
-
-## Prebuilt
-Backup.apk
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 35a40e4e..f653d484 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -4,15 +4,6 @@
-
@@ -146,4 +137,4 @@
-
\ No newline at end of file
+
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 79ee123c..0f7bc519 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/.idea/dictionaries/user.xml b/.idea/dictionaries/user.xml
new file mode 100644
index 00000000..131779fd
--- /dev/null
+++ b/.idea/dictionaries/user.xml
@@ -0,0 +1,14 @@
+
+
+
+ apk
+ chunker
+ ejectable
+ hasher
+ hkdf
+ restorable
+ seedvault
+ snowden
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index d467c379..e250dc34 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -7,4 +7,4 @@
-
\ No newline at end of file
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
index 7f68460d..e2456cbd 100644
--- a/.idea/runConfigurations.xml
+++ b/.idea/runConfigurations.xml
@@ -3,10 +3,11 @@
-
\ No newline at end of file
+
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..b7b22097 100644
--- a/.idea/runConfigurations/Instrumentation_Tests.xml
+++ b/.idea/runConfigurations/Instrumentation_tests__app.xml
@@ -1,5 +1,5 @@
-
+
@@ -48,4 +48,4 @@
-
\ No newline at end of file
+
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..8d7d3811 100644
--- a/.idea/runConfigurations/Unit_Tests.xml
+++ b/.idea/runConfigurations/Unit_tests__app.xml
@@ -1,5 +1,5 @@
-
+
@@ -14,4 +14,4 @@
-
\ No newline at end of file
+
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..5664d6d1 100644
--- a/Android.bp
+++ b/Android.bp
@@ -31,10 +31,17 @@ 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",
- "seedvault-lib-novacrypto-bip39",
+ // bip39
+ "seedvault-lib-kotlin-bip39",
],
manifest: "app/src/main/AndroidManifest.xml",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c80cc673..c7ab98e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,66 @@
+## [11-2.2] - 2021-09-29
+### User-facing changes
+* Don't backup on metered networks
+* Disable spell-checker on recovery code input
+* Disable Nextcloud restore when not installed and no store available
+* Ask for system authentication before storing a new recovery code
+* Prevent screenshots of recovery code
+* Add expert settings with an option for unlimited quota
+* Allow launching restore through a dialer code
+* Restrict exported components
+
+### Others
+* Improve .editorconfig setup
+* Move LocalContactsBackup to product partition
+* Link FAQ in Readme to make it more discoverable
+* Compares kotlin-bip39 library with bitcoinj library
+* Provide an overview over key derivations
+* document potential information leakage through the long-lived SQL caches
+* Add warning for third-party tools to README
+
+## [11-2.1] - 2021-07-06
+### Updated
+* Switch to a different BIP39 library due to licensing
+
+### Notes
+* Not tagged as a stable release
+
+## [11-2.0] - 2021-07-05
+### Added
+* Storage backup!
+
+### Notes
+* Not tagged as a stable release
+
+## [11-1.2] - 2021-07-05
+### Fixed
+* Fix local contacts backup on LineageOS.
+* Minor string fixes.
+* Make recovery code fit on smaller screens / larger densities
+* Sync app colors with system Settings theme for consistency
+
+### Updated
+* Translations update, both existing languages and new.
+* Switch all text references to github.com/seedvault-app
+
+## [11-1.1] - 2021-04-16
+### Fixed
+* Don't crash when storage app gets uninstalled
+
+### Added
+* Allow verifying and re-generating the 12 word recovery code
+* Prepare for storage backup
+* gradle: Use AOSP platform key for signing
+
+## [11-1.0] - 2021-04-16
+### Notes
+* Change versioning scheme to include Android version
+* 11-1.0 is the first release for Android 11
+* Incomplete changelog entry, lots of changes done
+
## [1.0.0] - 2020-03-07
-## Added
+### Added
- APK backup and restore support with the option to toggle them off.
- Note to auto-restore setting in case removable storage is used.
- UX for showing which packages were restored and which failed.
@@ -8,7 +68,7 @@
- Show list of apps and their backup status.
- Support for excluding apps from backups.
-## Fixed
+### Fixed
- Device initialization and generation of new backup tokens.
## [1.0.0-alpha1] - 2019-12-14
diff --git a/LICENSE b/LICENSE
index 1b294844..e8b688fb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -199,4 +199,4 @@
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
- limitations under the License.
\ No newline at end of file
+ limitations under the License.
diff --git a/README.md b/README.md
index 49410804..f9f6aa44 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,14 @@
A backup application for the [Android Open Source Project](https://source.android.com/).
+If you are having an issue/question, please look at our [FAQ](../../wiki/FAQ).
+
+## Components
+
+* [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts
+* [Storage library](storage) - a library handling efficient backup of files
+* [Seedvault app](app) - the main app where all functionality comes together
+
## Features
- Backup application data to a flash drive.
- Restore application data from a flash drive.
@@ -30,7 +38,11 @@ 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.
+* `android.permission.USE_BIOMETRIC` to authenticate saving a new recovery code
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.
@@ -39,6 +51,9 @@ This project aims to adhere to the [official Kotlin coding style](https://develo
## Third-party tools
+> **⚠ WARNING**: the Seedvault developers make no guarantees about external software projects.
+> Please be aware that disclosing your secret recovery key to other software has security risks.
+
The [Seedvault backup parser](https://github.com/tlambertz/seedvault_backup_parser)
allows you to decrypt and inspect your backups.
It can also re-encrypt them.
diff --git a/app/build.gradle b/app/build.gradle
index 3159d385..2f5208c1 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,19 +81,77 @@ android {
buildTypes.debug.signingConfig = signingConfigs.aosp
}
-apply from: '../gradle/dependencies.gradle'
+dependencies {
+ compileOnly rootProject.ext.aosp_libs
-ktlint {
- version = "0.36.0" // https://github.com/pinterest/ktlint/issues/764
- android = true
- enableExperimentalRules = false
- verbose = true
- disabledRules = [
- "import-ordering",
- "no-blank-line-before-rbrace",
- ]
+ /**
+ * 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: ['kotlin-bip39-1.0.2.jar'], dir: "${rootProject.rootDir}/libs")
+
+ /**
+ * 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 "org.junit.jupiter:junit-jupiter-params:$junit5_version"
+ testImplementation "io.mockk:mockk:$mockk_version"
+ testImplementation 'org.bitcoinj:bitcoinj-core:0.15.10'
+ 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"
}
+apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
+
gradle.projectsEvaluated {
tasks.withType(JavaCompile) {
if (JavaVersion.current() >= JavaVersion.VERSION_1_9) {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2fc78cf4..f08fa0fb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,8 +2,8 @@
+ android:versionCode="30000221"
+ android:versionName="11-2.2">
+
+
+
+
+
+
+
+
@@ -113,5 +128,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index a99e08a6..a443b3bc 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 { RecoveryCodeViewModel(this@App, get(), get(), get()) }
- viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
+ viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) }
+ viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get()) }
+ viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
- viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
+ viewModel { RestoreViewModel(this@App, get(), 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/SecretCodeReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/SecretCodeReceiver.kt
new file mode 100644
index 00000000..cd875ac0
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/SecretCodeReceiver.kt
@@ -0,0 +1,24 @@
+package com.stevesoltys.seedvault
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.util.Log
+import com.stevesoltys.seedvault.restore.RestoreActivity
+
+private val TAG = BroadcastReceiver::class.java.simpleName
+private val RESTORE_SECRET_CODE = "7378673"
+
+class SecretCodeReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val host = intent.data.host
+ if (!RESTORE_SECRET_CODE.equals(host)) return
+ Log.d(TAG, "Restore secret code received.")
+ val i = Intent(context, RestoreActivity::class.java).apply {
+ flags = FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(i)
+ }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
index c225ac6e..8f175a3f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
@@ -14,9 +14,12 @@ import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.util.Log
+import androidx.core.content.ContextCompat.startForegroundService
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.storage.StorageBackupService
+import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import org.koin.core.context.KoinContextHandler.get
@@ -54,9 +57,16 @@ class UsbIntentReceiver : UsbMonitor() {
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
- Thread {
- requestBackup(context)
- }.start()
+ if (settingsManager.isStorageBackupEnabled()) {
+ val i = Intent(context, StorageBackupService::class.java)
+ // this starts an app backup afterwards
+ i.putExtra(EXTRA_START_APP_BACKUP, true)
+ startForegroundService(context, i)
+ } else {
+ Thread {
+ requestBackup(context)
+ }.start()
+ }
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
index 9789b434..c71d4509 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
@@ -44,11 +44,6 @@ internal class DocumentsProviderBackupPlugin(
storage.currentFullBackupDir ?: throw IOException()
}
- @Throws(IOException::class)
- override suspend fun deleteAllBackups() {
- storage.rootBackupDir?.deleteContents(context)
- }
-
@Throws(IOException::class)
override suspend fun getMetadataOutputStream(): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
index b1f38fa8..596ee1f4 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
@@ -24,6 +24,9 @@ internal class DocumentsProviderRestorePlugin(
override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin {
+ private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
+ private val chunkFolderRegex = Regex("[a-f0-9]{2}")
+
@Throws(IOException::class)
override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
@@ -59,18 +62,21 @@ internal class DocumentsProviderRestorePlugin(
return backupSets
}
for (set in files) {
+ // retrieve name only once as this causes a DB query
+ val name = set.name
+
// get current token from set or continue to next file/set
- val token = set.getTokenOrNull() ?: continue
+ val token = set.getTokenOrNull(name) ?: continue
// block until children of set are available
val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
- Log.e(TAG, "Error reading metadata file in backup set folder: ${set.name}", e)
+ Log.e(TAG, "Error reading metadata file in backup set folder: $name", e)
null
}
if (metadata == null) {
- Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
+ Log.w(TAG, "Missing metadata file in backup set folder: $name")
} else {
backupSets.add(BackupSet(token, metadata))
}
@@ -78,21 +84,29 @@ internal class DocumentsProviderRestorePlugin(
return backupSets
}
- private fun DocumentFile.getTokenOrNull(): Long? {
- if (!isDirectory || name == null) {
- if (name != FILE_NO_MEDIA) {
+ private fun DocumentFile.getTokenOrNull(name: String?): Long? {
+ val looksLikeToken = name != null && tokenRegex.matches(name)
+ // check for isDirectory only if we already have a valid token (causes DB query)
+ if (!looksLikeToken || !isDirectory) {
+ // only log unexpected output
+ if (name != null && isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
return try {
- name!!.toLong()
+ name?.toLong()
} catch (e: NumberFormatException) {
- Log.w(TAG, "Found invalid backup set folder: $name")
- null
+ throw AssertionError(e)
}
}
+ private fun isUnexpectedFile(name: String): Boolean {
+ return name != FILE_NO_MEDIA &&
+ !chunkFolderRegex.matches(name) &&
+ !name.endsWith(".SeedSnap")
+ }
+
@Throws(IOException::class)
override suspend fun getApkInputStream(
token: Long,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
index 635d10d7..9b78d7d3 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
@@ -5,6 +5,8 @@ import androidx.annotation.CallSuper
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
import com.stevesoltys.seedvault.restore.install.InstallProgressFragment
import com.stevesoltys.seedvault.ui.LiveEventHandler
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
@@ -28,6 +30,8 @@ class RestoreActivity : RequireProvisioningActivity() {
when (fragment) {
RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
+ RESTORE_FILES -> showFragment(RestoreFilesFragment())
+ RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment())
else -> throw AssertionError()
}
})
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
new file mode 100644
index 00000000..235ce9ab
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
@@ -0,0 +1,66 @@
+package com.stevesoltys.seedvault.restore
+
+import android.app.Activity.RESULT_OK
+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 android.widget.TextView
+import androidx.fragment.app.Fragment
+import com.stevesoltys.seedvault.R
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.ui.restore.SnapshotFragment
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+
+internal class RestoreFilesFragment : SnapshotFragment() {
+ override val viewModel: RestoreViewModel by sharedViewModel()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val v = super.onCreateView(inflater, container, savedInstanceState)
+
+ val topStub: ViewStub = v.findViewById(R.id.topStub)
+ topStub.layoutResource = R.layout.header_snapshots
+ topStub.inflate()
+
+ val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
+ bottomStub.layoutResource = R.layout.footer_snapshots
+ val footer = bottomStub.inflate()
+ val skipView: TextView = footer.findViewById(R.id.skipView)
+ skipView.setOnClickListener {
+ requireActivity().apply {
+ setResult(RESULT_OK)
+ finishAfterTransition()
+ }
+ }
+ return v
+ }
+
+ override fun onSnapshotClicked(item: SnapshotItem) {
+ viewModel.startFilesRestore(item)
+ }
+}
+
+internal class RestoreFilesStartedFragment : Fragment() {
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val v: View = inflater.inflate(R.layout.fragment_restore_files_started, container, false)
+
+ val button: Button = v.findViewById(R.id.button)
+ button.setOnClickListener {
+ requireActivity().apply {
+ setResult(RESULT_OK)
+ finishAfterTransition()
+ }
+ }
+ return v
+ }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
index e3b4ce77..adc8ced4 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
@@ -1,6 +1,5 @@
package com.stevesoltys.seedvault.restore
-import android.app.Activity.RESULT_OK
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -38,7 +37,7 @@ class RestoreProgressFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
progressBar = v.findViewById(R.id.progressBar)
@@ -61,8 +60,7 @@ class RestoreProgressFragment : Fragment() {
button.setText(R.string.restore_finished_button)
button.setOnClickListener {
- requireActivity().setResult(RESULT_OK)
- requireActivity().finishAfterTransition()
+ viewModel.onFinishClickedAfterRestoringAppData()
}
// decryption will fail when the device is locked, so keep the screen on to prevent locking
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
index b04a56e6..9cefe48a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
@@ -22,19 +22,19 @@ class RestoreSetFragment : Fragment() {
private lateinit var listView: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var errorView: TextView
- private lateinit var backView: TextView
+ private lateinit var skipView: TextView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
val v: View = inflater.inflate(R.layout.fragment_restore_set, container, false)
listView = v.findViewById(R.id.listView)
progressBar = v.findViewById(R.id.progressBar)
errorView = v.findViewById(R.id.errorView)
- backView = v.findViewById(R.id.backView)
+ skipView = v.findViewById(R.id.skipView)
return v
}
@@ -49,7 +49,9 @@ class RestoreSetFragment : Fragment() {
onRestoreResultsLoaded(result)
})
- backView.setOnClickListener { requireActivity().finishAfterTransition() }
+ skipView.setOnClickListener {
+ viewModel.onFinishClickedAfterRestoringAppData()
+ }
}
override fun onStart() {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
index 199de755..2b4e398f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
@@ -5,9 +5,11 @@ import android.app.backup.IBackupManager
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
+import android.content.Intent
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
+import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@@ -27,11 +29,14 @@ import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
import com.stevesoltys.seedvault.restore.install.ApkRestore
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
import com.stevesoltys.seedvault.restore.install.InstallResult
import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.storage.StorageRestoreService
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState
@@ -54,6 +59,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
+import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
+import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.util.LinkedList
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -68,8 +78,10 @@ internal class RestoreViewModel(
private val backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore,
+ storageBackup: StorageBackup,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
-) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener {
+) : RequireProvisioningViewModel(app, settingsManager, keyManager),
+ RestorableBackupClickListener, SnapshotViewModel {
override val isRestoreOperation = true
@@ -110,6 +122,8 @@ internal class RestoreViewModel(
private val mRestoreBackupResult = MutableLiveData()
internal val restoreBackupResult: LiveData get() = mRestoreBackupResult
+ override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
+
@Throws(RemoteException::class)
private fun getOrStartSession(): IRestoreSession {
val session = this.session
@@ -168,7 +182,7 @@ internal class RestoreViewModel(
.asLiveData(ioDispatcher)
}
- internal fun onNextClicked() {
+ internal fun onNextClickedAfterInstallingApps() {
mDisplayFragment.postEvent(RESTORE_BACKUP)
val token = mChosenRestorableBackup.value?.token ?: throw AssertionError()
viewModelScope.launch(ioDispatcher) {
@@ -371,6 +385,20 @@ internal class RestoreViewModel(
}
+ @UiThread
+ internal fun onFinishClickedAfterRestoringAppData() {
+ mDisplayFragment.setEvent(RESTORE_FILES)
+ }
+
+ @UiThread
+ internal fun startFilesRestore(item: SnapshotItem) {
+ val i = Intent(app, StorageRestoreService::class.java)
+ i.putExtra(EXTRA_USER_ID, item.storedSnapshot.userId)
+ i.putExtra(EXTRA_TIMESTAMP_START, item.time)
+ app.startForegroundService(i)
+ mDisplayFragment.setEvent(RESTORE_FILES_STARTED)
+ }
+
}
internal class RestoreSetResult(
@@ -389,4 +417,6 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
internal fun hasError(): Boolean = errorMsg != null
}
-internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP }
+internal enum class DisplayFragment {
+ RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
index 9388c7e2..a4c2c813 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
@@ -59,7 +59,7 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
button.setText(R.string.restore_next)
- button.setOnClickListener { viewModel.onNextClicked() }
+ button.setOnClickListener { viewModel.onNextClickedAfterInstallingApps() }
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, Observer { restorableBackup ->
backupNameView.text = restorableBackup.name
@@ -76,7 +76,7 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install
- if (installResult.isEmpty) viewModel.onNextClicked()
+ if (installResult.isEmpty) viewModel.onNextClickedAfterInstallingApps()
// if finished, treat all still queued apps as failed and resort/redisplay adapter items
if (installResult.isFinished) {
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/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
new file mode 100644
index 00000000..05164d78
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
@@ -0,0 +1,19 @@
+package com.stevesoltys.seedvault.settings
+
+import android.os.Bundle
+import androidx.preference.PreferenceFragmentCompat
+import com.stevesoltys.seedvault.R
+import com.stevesoltys.seedvault.permitDiskReads
+
+class ExpertSettingsFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ permitDiskReads {
+ setPreferencesFromResource(R.xml.settings_expert, rootKey)
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ activity?.setTitle(R.string.settings_expert_title)
+ }
+}
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..0057d9c1 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() {
@@ -147,6 +166,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
+ R.id.action_settings_expert -> {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment, ExpertSettingsFragment())
+ .addToBackStack(null)
+ .commit()
+ true
+ }
R.id.action_about -> {
AboutDialogFragment().show(parentFragmentManager, AboutDialogFragment.TAG)
true
@@ -190,4 +216,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/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
index 0217638d..6d59857f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -30,6 +30,9 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
+private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
+private const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
+
class SettingsManager(private val context: Context) {
private val prefs = permitDiskReads {
@@ -48,10 +51,10 @@ class SettingsManager(private val context: Context) {
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
}
- fun getToken(): Long? = token ?: {
+ fun getToken(): Long? = token ?: run {
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
if (value == 0L) null else value
- }()
+ }
/**
* Sets a new RestoreSet token.
@@ -138,6 +141,8 @@ class SettingsManager(private val context: Context) {
fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName)
+ fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false)
+
@UiThread
fun onAppBackupStatusChanged(status: AppStatus) {
if (status.enabled) blacklistedApps.remove(status.packageName)
@@ -145,6 +150,7 @@ class SettingsManager(private val context: Context) {
prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply()
}
+ fun isQuotaUnlimited() = prefs.getBoolean(PREF_KEY_UNLIMITED_QUOTA, false)
}
data class Storage(
@@ -171,13 +177,14 @@ data class Storage(
* but it isn't available right now.
*/
fun isUnavailableNetwork(context: Context): Boolean {
- return requiresNetwork && !hasInternet(context)
+ return requiresNetwork && !hasUnmeteredInternet(context)
}
- private fun hasInternet(context: Context): Boolean {
+ private fun hasUnmeteredInternet(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
+ val isMetered = cm.isActiveNetworkMetered()
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
- return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && !isMetered
}
}
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..e393a7df 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,9 @@
package com.stevesoltys.seedvault.settings
import android.app.Application
+import android.app.job.JobInfo.NETWORK_TYPE_NONE
+import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED
+import android.content.Intent
import android.database.ContentObserver
import android.net.ConnectivityManager
import android.net.Network
@@ -12,6 +15,7 @@ import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.annotation.UiThread
+import androidx.core.content.ContextCompat.startForegroundService
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.switchMap
@@ -22,11 +26,17 @@ 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.storage.StorageBackupService
+import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
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 +47,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 +70,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 +103,7 @@ internal class SettingsViewModel(
metadataManager.getLastBackupTime()
}
onStorageLocationChanged()
+ loadFilesSummary()
}
override fun onStorageLocationChanged() {
@@ -116,6 +131,14 @@ internal class SettingsViewModel(
networkCallback.registered = true
}
+ if (settingsManager.isStorageBackupEnabled()) {
+ // disable storage backup if new storage is on USB
+ if (storage.isUsb) disableStorageBackup()
+ // enable it, just in case the previous storage was on USB,
+ // also to update the network requirement of the new storage
+ else enableStorageBackup()
+ }
+
viewModelScope.launch(Dispatchers.IO) {
val canDo = settingsManager.canDoBackupNow()
mBackupPossible.postValue(canDo)
@@ -134,8 +157,15 @@ 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) {
+ if (settingsManager.isStorageBackupEnabled()) {
+ val i = Intent(app, StorageBackupService::class.java)
+ // this starts an app backup afterwards
+ i.putExtra(EXTRA_START_APP_BACKUP, true)
+ startForegroundService(app, i)
+ } else {
+ requestBackup(app)
+ }
}
}
@@ -156,6 +186,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 +208,25 @@ internal class SettingsViewModel(
}
}
+ fun hasMainKey(): Boolean {
+ return keyManager.hasMainKey()
+ }
+
+ fun enableStorageBackup() {
+ val storage = settingsManager.getStorage() ?: error("no storage available")
+ if (!storage.isUsb) BackupJobService.scheduleJob(
+ context = app,
+ jobServiceClass = StorageBackupJobService::class.java,
+ periodMillis = HOURS.toMillis(24),
+ networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED
+ else NETWORK_TYPE_NONE,
+ 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..1c54beb2
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
@@ -0,0 +1,55 @@
+package com.stevesoltys.seedvault.storage
+
+import android.content.Intent
+import com.stevesoltys.seedvault.transport.requestBackup
+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 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() {
+
+ companion object {
+ internal const val EXTRA_START_APP_BACKUP = "startAppBackup"
+ }
+
+ 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)
+ }
+
+ override fun onBackupFinished(intent: Intent, success: Boolean) {
+ if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
+ requestBackup(applicationContext)
+ }
+ }
+}
+
+internal class StorageRestoreService : RestoreService() {
+ override val storageBackup: StorageBackup by inject()
+
+ // use lazy delegate because context isn't available during construction time
+ override val restoreObserver: RestoreObserver by lazy {
+ NotificationRestoreObserver(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/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
index 328ff1bd..7ab65f46 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
@@ -2,10 +2,8 @@ package com.stevesoltys.seedvault.transport
import android.app.Service
import android.app.backup.BackupManager
-import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP // ktlint-disable no-unused-imports
import android.app.backup.IBackupManager
import android.content.Context
-import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
import android.content.Intent
import android.os.IBinder
import android.os.RemoteException
@@ -55,22 +53,27 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
@WorkerThread
fun requestBackup(context: Context) {
- val packageService: PackageService = get().get()
- val packages = packageService.eligiblePackages
- val appTotals = packageService.expectedAppTotals
+ val backupManager: IBackupManager = get().get()
+ if (backupManager.isBackupEnabled) {
+ val packageService: PackageService = get().get()
+ val packages = packageService.eligiblePackages
+ val appTotals = packageService.expectedAppTotals
- val observer = NotificationBackupObserver(context, packages.size, appTotals)
- val result = try {
- val backupManager: IBackupManager = get().get()
- backupManager.requestBackup(packages, observer, BackupMonitor(), 0)
- } catch (e: RemoteException) {
- Log.e(TAG, "Error during backup: ", e)
- val nm: BackupNotificationManager = get().get()
- nm.onBackupError()
- }
- if (result == BackupManager.SUCCESS) {
- Log.i(TAG, "Backup succeeded ")
+ val result = try {
+ Log.d(TAG, "Backup is enabled, request backup...")
+ val observer = NotificationBackupObserver(context, packages.size, appTotals)
+ backupManager.requestBackup(packages, observer, BackupMonitor(), 0)
+ } catch (e: RemoteException) {
+ Log.e(TAG, "Error during backup: ", e)
+ val nm: BackupNotificationManager = get().get()
+ nm.onBackupError()
+ }
+ if (result == BackupManager.SUCCESS) {
+ Log.i(TAG, "Backup succeeded ")
+ } else {
+ Log.e(TAG, "Backup failed: $result")
+ }
} else {
- Log.e(TAG, "Backup failed: $result")
+ Log.i(TAG, "Backup is not enabled")
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index fd4bd5e4..69c4cd8f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
@@ -321,7 +321,7 @@ internal class BackupCoordinator(
?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(
TAG, "Cancel full backup of ${packageInfo.packageName}" +
- " because of $state.cancelReason"
+ " because of ${state.cancelReason}"
)
onPackageBackupError(packageInfo)
full.cancelFullBackup()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
index f2a3378c..85ee1962 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
@@ -21,6 +21,7 @@ val backupModule = module {
single {
KVBackup(
plugin = get().kvBackupPlugin,
+ settingsManager = get(),
inputFactory = get(),
headerWriter = get(),
crypto = get(),
@@ -30,6 +31,7 @@ val backupModule = module {
single {
FullBackup(
plugin = get().fullBackupPlugin,
+ settingsManager = get(),
inputFactory = get(),
headerWriter = get(),
crypto = get()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
index 5a08a63a..f8672503 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
@@ -25,12 +25,6 @@ interface BackupPlugin {
@Throws(IOException::class)
suspend fun initializeDevice()
- /**
- * Delete all existing [RestoreSet]s from the storage medium.
- */
- @Throws(IOException::class)
- suspend fun deleteAllBackups()
-
/**
* Returns an [OutputStream] for writing backup metadata.
*/
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
index 85e2591c..b7ba16e0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
@@ -11,6 +11,7 @@ import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.VersionHeader
+import com.stevesoltys.seedvault.settings.SettingsManager
import libcore.io.IoUtils.closeQuietly
import java.io.EOFException
import java.io.IOException
@@ -35,6 +36,7 @@ private val TAG = FullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackup(
private val plugin: FullBackupPlugin,
+ private val settingsManager: SettingsManager,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto
@@ -46,7 +48,9 @@ internal class FullBackup(
fun getCurrentPackage() = state?.packageInfo
- fun getQuota(): Long = plugin.getQuota()
+ fun getQuota(): Long {
+ return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else plugin.getQuota()
+ }
fun checkFullBackupSize(size: Long): Int {
Log.i(TAG, "Check full backup size of $size bytes.")
@@ -134,7 +138,7 @@ internal class FullBackup(
// check if size fits quota
state.size += numBytes
- val quota = plugin.getQuota()
+ val quota = getQuota()
if (state.size > quota) {
Log.w(
TAG,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
index 153b0989..27455aea 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
@@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.VersionHeader
+import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
@@ -27,6 +28,7 @@ private val TAG = KVBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup(
private val plugin: KVBackupPlugin,
+ private val settingsManager: SettingsManager,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto,
@@ -39,7 +41,9 @@ internal class KVBackup(
fun getCurrentPackage() = state?.packageInfo
- fun getQuota(): Long = plugin.getQuota()
+ fun getQuota(): Long {
+ return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else plugin.getQuota()
+ }
suspend fun performBackup(
packageInfo: PackageInfo,
@@ -94,7 +98,7 @@ internal class KVBackup(
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
}
- // TODO check if package is over-quota
+ // TODO check if package is over-quota and respect unlimited setting
if (isNonIncremental && hasDataForPackage) {
Log.w(TAG, "Requested non-incremental, deleting existing data.")
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/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeActivity.kt
index bca1f7ca..63b45a67 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeActivity.kt
@@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.ui.recoverycode
import android.os.Bundle
import android.view.MenuItem
+import android.view.WindowManager.LayoutParams.FLAG_SECURE
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.BackupActivity
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
@@ -17,6 +18,7 @@ class RecoveryCodeActivity : BackupActivity() {
super.onCreate(savedInstanceState)
if (isSetupWizard()) hideSystemUiNavigation()
+ window.addFlags(FLAG_SECURE)
setContentView(R.layout.activity_recovery_code)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
index 606e024b..1003300e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
import com.stevesoltys.seedvault.R
-class RecoveryCodeAdapter(private val items: List) :
+class RecoveryCodeAdapter(private val items: List) :
Adapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecoveryCodeViewHolder {
@@ -30,9 +30,9 @@ class RecoveryCodeViewHolder(v: View) : RecyclerView.ViewHolder(v) {
private val num = v.findViewById(R.id.num)
private val word = v.findViewById(R.id.word)
- internal fun bind(number: Int, item: CharSequence) {
+ internal fun bind(number: Int, item: CharArray) {
num.text = number.toString()
- word.text = item
+ word.text = String(item)
}
}
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..8cb7fd89 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
@@ -1,8 +1,14 @@
package com.stevesoltys.seedvault.ui.recoverycode
import android.app.Activity.RESULT_OK
+import android.app.KeyguardManager
import android.content.Intent
+import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG
+import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL
+import android.hardware.biometrics.BiometricPrompt
+import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
+import android.os.CancellationSignal
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
@@ -16,20 +22,23 @@ import android.widget.TextView
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat.getMainExecutor
import androidx.fragment.app.Fragment
+import cash.z.ecc.android.bip39.Mnemonics
+import cash.z.ecc.android.bip39.Mnemonics.ChecksumException
+import cash.z.ecc.android.bip39.Mnemonics.InvalidWordException
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputLayout
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.isDebugBuild
import com.stevesoltys.seedvault.ui.LiveEventHandler
-import io.github.novacrypto.bip39.Validation.InvalidChecksumException
-import io.github.novacrypto.bip39.Validation.WordNotFoundException
-import io.github.novacrypto.bip39.wordlists.English
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import java.util.Locale
-internal const val ARG_FOR_NEW_CODE = "forVerifyingNewCode"
+internal const val ARG_FOR_NEW_CODE = "forStoringNewCode"
class RecoveryCodeInputFragment : Fragment() {
@@ -56,13 +65,13 @@ class RecoveryCodeInputFragment : Fragment() {
/**
* True if this is for verifying a new recovery code, false for verifying an existing one.
*/
- private var forVerifyingNewCode: Boolean = true
+ private var forStoringNewCode: Boolean = true
override fun onCreateView(
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)
@@ -84,7 +93,7 @@ class RecoveryCodeInputFragment : Fragment() {
wordList = v.findViewById(R.id.wordList)
arguments?.getBoolean(ARG_FOR_NEW_CODE, true)?.let {
- forVerifyingNewCode = it
+ forStoringNewCode = it
}
return v
@@ -93,13 +102,18 @@ 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
backView.setOnClickListener { requireActivity().finishAfterTransition() }
}
- val adapter = getAdapter()
+ val adapterLayout = android.R.layout.simple_list_item_1
+ val adapter = ArrayAdapter(requireContext(), adapterLayout).apply {
+ addAll(Mnemonics.getCachedWords(Locale.ENGLISH.language))
+ }
for (i in 0 until WORD_NUM) {
val wordLayout = getWordLayout(i)
@@ -110,22 +124,14 @@ class RecoveryCodeInputFragment : Fragment() {
editText.setAdapter(adapter)
}
doneButton.setOnClickListener { done() }
- newCodeButton.visibility = if (forVerifyingNewCode) GONE else VISIBLE
+ newCodeButton.visibility = if (forStoringNewCode) GONE else VISIBLE
newCodeButton.setOnClickListener { generateNewCode() }
viewModel.existingCodeChecked.observeEvent(viewLifecycleOwner,
LiveEventHandler { verified -> onExistingCodeChecked(verified) }
)
- if (forVerifyingNewCode && isDebugBuild() && !viewModel.isRestore) debugPreFill()
- }
-
- private fun getAdapter(): ArrayAdapter {
- val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1)
- for (i in 0 until WORD_LIST_SIZE) {
- adapter.add(English.INSTANCE.getWord(i))
- }
- return adapter
+ if (forStoringNewCode && isDebugBuild() && !viewModel.isRestore) debugPreFill()
}
private fun getInput(): List = ArrayList(WORD_NUM).apply {
@@ -136,12 +142,43 @@ class RecoveryCodeInputFragment : Fragment() {
val input = getInput()
if (!allFilledOut(input)) return
try {
- viewModel.validateAndContinue(input, forVerifyingNewCode)
- } catch (e: InvalidChecksumException) {
+ viewModel.validateCode(input)
+ } catch (e: ChecksumException) {
Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show()
- } catch (e: WordNotFoundException) {
- showWrongWordError(input, e)
+ return
+ } catch (e: InvalidWordException) {
+ showWrongWordError(input)
+ return
}
+ if (forStoringNewCode) {
+ val keyguardManager = requireContext().getSystemService(KeyguardManager::class.java)
+ if (SDK_INT >= 30 && keyguardManager.isDeviceSecure) {
+ // if we have a lock-screen secret, we can ask for it before storing the code
+ storeNewCodeAfterAuth(input)
+ } else {
+ // user doesn't seem to care about security, store key without auth
+ viewModel.storeNewCode(input)
+ }
+ } else {
+ viewModel.verifyExistingCode(input)
+ }
+ }
+
+ @RequiresApi(30)
+ private fun storeNewCodeAfterAuth(input: List) {
+ val biometricPrompt = BiometricPrompt.Builder(context)
+ .setConfirmationRequired(true)
+ .setTitle(getString(R.string.recovery_code_auth_title))
+ .setDescription(getString(R.string.recovery_code_auth_description))
+ // BIOMETRIC_STRONG could be made optional in the future, setting guarded by credentials
+ .setAllowedAuthenticators(DEVICE_CREDENTIAL or BIOMETRIC_STRONG)
+ .build()
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
+ viewModel.storeNewCode(input)
+ }
+ }
+ biometricPrompt.authenticate(CancellationSignal(), getMainExecutor(context), callback)
}
private fun allFilledOut(input: List): Boolean {
@@ -153,10 +190,11 @@ class RecoveryCodeInputFragment : Fragment() {
return true
}
- private fun showWrongWordError(input: List, e: WordNotFoundException) {
- val i = input.indexOf(e.word)
+ private fun showWrongWordError(input: List) {
+ val words = Mnemonics.getCachedWords(Locale.ENGLISH.language)
+ val i = input.indexOfFirst { it !in words }
if (i == -1) throw AssertionError()
- val str = getString(R.string.recovery_code_error_invalid_word, e.suggestion1, e.suggestion2)
+ val str = getString(R.string.recovery_code_error_invalid_word)
showError(i, str)
}
@@ -190,7 +228,7 @@ class RecoveryCodeInputFragment : Fragment() {
private val regenRequest = registerForActivityResult(StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
- viewModel.deleteAllBackup()
+ viewModel.reinitializeBackupLocation()
parentFragmentManager.popBackStack()
Snackbar.make(requireView(), R.string.recovery_code_recreated, Snackbar.LENGTH_LONG)
.show()
@@ -233,7 +271,7 @@ class RecoveryCodeInputFragment : Fragment() {
private fun debugPreFill() {
val words = viewModel.wordList
for (i in words.indices) {
- getWordLayout(i).editText!!.setText(words[i])
+ getWordLayout(i).editText!!.setText(String(words[i]))
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
index 35f6af76..d8b3a5ef 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
@@ -23,7 +23,7 @@ class RecoveryCodeOutputFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
val v: View = inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
wordList = v.findViewById(R.id.wordList)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
index c3069475..d851ea9e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
@@ -1,48 +1,47 @@
package com.stevesoltys.seedvault.ui.recoverycode
+import android.app.backup.IBackupManager
+import android.os.UserHandle
import android.util.Log
import androidx.lifecycle.AndroidViewModel
+import cash.z.ecc.android.bip39.Mnemonics
+import cash.z.ecc.android.bip39.Mnemonics.ChecksumException
+import cash.z.ecc.android.bip39.Mnemonics.InvalidWordException
+import cash.z.ecc.android.bip39.Mnemonics.WordCountException
+import cash.z.ecc.android.bip39.toSeed
import com.stevesoltys.seedvault.App
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.KeyManager
-import com.stevesoltys.seedvault.transport.backup.BackupPlugin
+import com.stevesoltys.seedvault.transport.TRANSPORT_ID
+import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
-import io.github.novacrypto.bip39.JavaxPBKDF2WithHmacSHA512
-import io.github.novacrypto.bip39.MnemonicGenerator
-import io.github.novacrypto.bip39.MnemonicValidator
-import io.github.novacrypto.bip39.SeedCalculator
-import io.github.novacrypto.bip39.Validation.InvalidChecksumException
-import io.github.novacrypto.bip39.Validation.InvalidWordCountException
-import io.github.novacrypto.bip39.Validation.UnexpectedWhiteSpaceException
-import io.github.novacrypto.bip39.Validation.WordNotFoundException
-import io.github.novacrypto.bip39.Words
-import io.github.novacrypto.bip39.wordlists.English
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.StorageBackup
import java.io.IOException
import java.security.SecureRandom
-import java.util.ArrayList
internal const val WORD_NUM = 12
-internal const val WORD_LIST_SIZE = 2048
-class RecoveryCodeViewModel(
+private val TAG = RecoveryCodeViewModel::class.java.simpleName
+
+internal class RecoveryCodeViewModel(
app: App,
private val crypto: Crypto,
private val keyManager: KeyManager,
- private val backupPlugin: BackupPlugin
+ private val backupManager: IBackupManager,
+ private val backupCoordinator: BackupCoordinator,
+ private val storageBackup: StorageBackup
) : AndroidViewModel(app) {
- internal val wordList: List by lazy {
- val items: ArrayList = ArrayList(WORD_NUM)
- val entropy = ByteArray(Words.TWELVE.byteLength())
+ internal val wordList: List by lazy {
+ // we use our own entropy to not having to trust the library to use SecureRandom
+ val entropy = ByteArray(Mnemonics.WordCount.COUNT_12.bitLength / 8)
SecureRandom().nextBytes(entropy)
- MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
- if (it != " ") items.add(it)
- }
- items
+ // create the words from the entropy
+ Mnemonics.MnemonicCode(entropy).words
}
private val mConfirmButtonClicked = MutableLiveEvent()
@@ -57,36 +56,72 @@ class RecoveryCodeViewModel(
internal var isRestore: Boolean = false
- @Throws(WordNotFoundException::class, InvalidChecksumException::class)
- fun validateAndContinue(input: List, forVerifyingNewCode: Boolean) {
+ @Throws(InvalidWordException::class, ChecksumException::class)
+ fun validateCode(input: List): Mnemonics.MnemonicCode {
+ val code = Mnemonics.MnemonicCode(input.toMnemonicChars())
try {
- MnemonicValidator.ofWordList(English.INSTANCE).validate(input)
- } catch (e: UnexpectedWhiteSpaceException) {
- throw AssertionError(e)
- } catch (e: InvalidWordCountException) {
+ code.validate()
+ } catch (e: WordCountException) {
throw AssertionError(e)
}
- val mnemonic = input.joinToString(" ")
- val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
- if (forVerifyingNewCode) {
- keyManager.storeBackupKey(seed)
- keyManager.storeMainKey(seed)
- mRecoveryCodeSaved.setEvent(true)
- } else {
- val verified = crypto.verifyBackupKey(seed)
- if (verified && !keyManager.hasMainKey()) keyManager.storeMainKey(seed)
- mExistingCodeChecked.setEvent(verified)
- }
+ return code
}
- fun deleteAllBackup() {
+ /**
+ * Verifies existing recovery code and returns result via [existingCodeChecked].
+ */
+ fun verifyExistingCode(input: List) {
+ // we validate the code again, just in case
+ val seed = validateCode(input).toSeed()
+ val verified = crypto.verifyBackupKey(seed)
+ // store main key at this opportunity if it is still missing
+ if (verified && !keyManager.hasMainKey()) keyManager.storeMainKey(seed)
+ mExistingCodeChecked.setEvent(verified)
+ }
+
+ /**
+ * Stores a new recovery code and returns result via [recoveryCodeSaved].
+ */
+ fun storeNewCode(input: List) {
+ // we validate the code again, just in case
+ val seed = validateCode(input).toSeed()
+ keyManager.storeBackupKey(seed)
+ keyManager.storeMainKey(seed)
+ mRecoveryCodeSaved.setEvent(true)
+ }
+
+ /**
+ * Deletes all storage backups for current user and clears the storage backup cache.
+ * Also starts a new app data restore set and initializes it.
+ *
+ * The reason is that old backups won't be readable anymore with the new key.
+ * We can't delete other backups safely, because we can't be sure
+ * that they don't belong to a different device or user.
+ */
+ fun reinitializeBackupLocation() {
+ Log.d(TAG, "Re-initializing backup location...")
GlobalScope.launch(Dispatchers.IO) {
+ // remove old storage snapshots and clear cache
+ storageBackup.deleteAllSnapshots()
+ storageBackup.clearCache()
try {
- backupPlugin.deleteAllBackups()
+ // will also generate a new backup token for the new restore set
+ backupCoordinator.startNewRestoreSet()
+
+ // initialize the new location
+ backupManager.initializeTransportsForUser(
+ UserHandle.myUserId(),
+ arrayOf(TRANSPORT_ID),
+ null
+ )
} catch (e: IOException) {
- Log.e("RecoveryCodeViewModel", "Error deleting backups", e)
+ Log.e(TAG, "Error starting new RestoreSet", e)
}
}
}
}
+
+internal fun List.toMnemonicChars(): CharArray {
+ return joinToString(" ").toCharArray()
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
index 617ab24a..aa8a0bb6 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
@@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.requestBackup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.StorageBackup
import java.io.IOException
private val TAG = BackupStorageViewModel::class.java.simpleName
@@ -24,6 +25,7 @@ internal class BackupStorageViewModel(
private val app: Application,
private val backupManager: IBackupManager,
private val backupCoordinator: BackupCoordinator,
+ private val storageBackup: StorageBackup,
settingsManager: SettingsManager
) : StorageViewModel(app, settingsManager) {
@@ -32,6 +34,9 @@ internal class BackupStorageViewModel(
override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(uri)
viewModelScope.launch(Dispatchers.IO) {
+ // remove old storage snapshots and clear cache
+ storageBackup.deleteAllSnapshots()
+ storageBackup.clearCache()
try {
// will also generate a new backup token for the new restore set
backupCoordinator.startNewRestoreSet()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
index 3c8c60d1..566d03fd 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
@@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.storage
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
+import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
@@ -37,7 +38,7 @@ class StorageCheckFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
val v: View = inflater.inflate(R.layout.fragment_storage_check, container, false)
titleView = v.findViewById(R.id.titleView)
@@ -55,6 +56,7 @@ class StorageCheckFragment : Fragment() {
val errorMsg = requireArguments().getString(ERROR_MSG)
if (errorMsg != null) {
+ view.findViewById(R.id.patienceView).visibility = GONE
progressBar.visibility = INVISIBLE
errorView.text = errorMsg
errorView.visibility = VISIBLE
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
index 643435e8..50027a6b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
@@ -156,11 +156,19 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
data = Uri.parse("nc://login/server:")
putExtra("onlyAdd", true)
}
+ val marketIntent =
+ Intent(ACTION_VIEW, Uri.parse("market://details?id=$NEXTCLOUD_PACKAGE")).apply {
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ }
val isInstalled = packageManager.resolveActivity(intent, 0) != null
+ val canInstall = packageManager.resolveActivity(marketIntent, 0) != null
val summaryRes = if (isInstalled) {
if (isRestore) R.string.storage_fake_nextcloud_summary_installed
else R.string.storage_fake_nextcloud_summary_unavailable
- } else R.string.storage_fake_nextcloud_summary
+ } else {
+ if (canInstall) R.string.storage_fake_nextcloud_summary
+ else R.string.storage_fake_nextcloud_summary_unavailable_market
+ }
val root = StorageRoot(
authority = AUTHORITY_NEXTCLOUD,
rootId = "fake",
@@ -171,15 +179,10 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
availableBytes = null,
isUsb = false,
requiresNetwork = true,
- enabled = !isInstalled || isRestore,
+ enabled = isInstalled || canInstall,
overrideClickListener = {
if (isInstalled) context.startActivity(intent)
- else {
- val uri = Uri.parse("market://details?id=$NEXTCLOUD_PACKAGE")
- val i = Intent(ACTION_VIEW, uri)
- i.addFlags(FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(i)
- }
+ else if (canInstall) context.startActivity(marketIntent)
}
)
roots.add(root)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
index ae872ad1..b50b24de 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
@@ -83,6 +83,9 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
backView.setOnClickListener { requireActivity().finishAfterTransition() }
} else {
warningIcon.visibility = VISIBLE
+ if (viewModel.hasStorageSet) {
+ warningText.setText(R.string.storage_fragment_warning_delete)
+ }
warningText.visibility = VISIBLE
divider.visibility = VISIBLE
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
index 0f12b0f5..f04eaa8d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
@@ -41,6 +41,8 @@ internal abstract class StorageViewModel(
private var storageRoot: StorageRoot? = null
internal var isSetupWizard: Boolean = false
+ internal val hasStorageSet: Boolean
+ get() = settingsManager.getStorage() != null
abstract val isRestoreOperation: Boolean
companion object {
diff --git a/app/src/main/res/drawable/ic_cloud_error.xml b/app/src/main/res/drawable/ic_cloud_error.xml
index ecb6ff7d..b1ddd057 100644
--- a/app/src/main/res/drawable/ic_cloud_error.xml
+++ b/app/src/main/res/drawable/ic_cloud_error.xml
@@ -7,4 +7,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/drawable/ic_launcher_default.xml b/app/src/main/res/drawable/ic_launcher_default.xml
index 2b714bdb..8400e476 100644
--- a/app/src/main/res/drawable/ic_launcher_default.xml
+++ b/app/src/main/res/drawable/ic_launcher_default.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
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 @@
\ No newline at end of file
+ android:layout_height="match_parent" />
diff --git a/app/src/main/res/layout/activity_recovery_code.xml b/app/src/main/res/layout/activity_recovery_code.xml
index d64f58e8..a7f5302e 100644
--- a/app/src/main/res/layout/activity_recovery_code.xml
+++ b/app/src/main/res/layout/activity_recovery_code.xml
@@ -2,4 +2,4 @@
\ No newline at end of file
+ android:layout_height="match_parent" />
diff --git a/app/src/main/res/layout/footer_snapshots.xml b/app/src/main/res/layout/footer_snapshots.xml
new file mode 100644
index 00000000..d6c75b3b
--- /dev/null
+++ b/app/src/main/res/layout/footer_snapshots.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_recovery_code_input.xml b/app/src/main/res/layout/fragment_recovery_code_input.xml
index 03521fdf..52d4ce97 100644
--- a/app/src/main/res/layout/fragment_recovery_code_input.xml
+++ b/app/src/main/res/layout/fragment_recovery_code_input.xml
@@ -75,7 +75,8 @@
android:id="@+id/backView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_margin="16dp"
+ android:background="?android:selectableItemBackground"
+ android:padding="16dp"
android:text="@string/restore_back"
android:textColor="?android:colorAccent"
android:visibility="gone"
@@ -103,4 +104,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_recovery_code_output.xml b/app/src/main/res/layout/fragment_recovery_code_output.xml
index 54560f51..effa6efe 100644
--- a/app/src/main/res/layout/fragment_recovery_code_output.xml
+++ b/app/src/main/res/layout/fragment_recovery_code_output.xml
@@ -83,4 +83,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordList" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_restore_files_started.xml b/app/src/main/res/layout/fragment_restore_files_started.xml
new file mode 100644
index 00000000..8746717b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_restore_files_started.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_restore_set.xml b/app/src/main/res/layout/fragment_restore_set.xml
index 9d3f85bd..eba8d78e 100644
--- a/app/src/main/res/layout/fragment_restore_set.xml
+++ b/app/src/main/res/layout/fragment_restore_set.xml
@@ -36,7 +36,7 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
- app:layout_constraintBottom_toTopOf="@+id/backView"
+ app:layout_constraintBottom_toTopOf="@+id/skipView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
@@ -47,7 +47,7 @@
style="?android:progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- app:layout_constraintBottom_toTopOf="@+id/backView"
+ app:layout_constraintBottom_toTopOf="@+id/skipView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
@@ -60,7 +60,7 @@
android:textColor="?android:colorError"
android:textSize="18sp"
android:visibility="invisible"
- app:layout_constraintBottom_toTopOf="@+id/backView"
+ app:layout_constraintBottom_toTopOf="@+id/skipView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
@@ -68,11 +68,12 @@
tools:visibility="visible" />
+
+
diff --git a/app/src/main/res/layout/fragment_storage_root.xml b/app/src/main/res/layout/fragment_storage_root.xml
index a6267bb1..3263f5b6 100644
--- a/app/src/main/res/layout/fragment_storage_root.xml
+++ b/app/src/main/res/layout/fragment_storage_root.xml
@@ -22,24 +22,24 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
+ android:gravity="center"
android:text="@string/storage_fragment_backup_title"
android:textSize="24sp"
- android:gravity="center"
- tools:text="Choose where to store backup (is a short title, but it can be longer)"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/imageView" />
+ app:layout_constraintTop_toBottomOf="@+id/imageView"
+ tools:text="Choose where to store backup (is a short title, but it can be longer)" />
@@ -96,7 +96,8 @@
android:id="@+id/backView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_margin="16dp"
+ android:background="?android:selectableItemBackground"
+ android:padding="16dp"
android:text="@string/restore_back"
android:textColor="?android:colorAccent"
android:visibility="gone"
diff --git a/app/src/main/res/layout/header_snapshots.xml b/app/src/main/res/layout/header_snapshots.xml
new file mode 100644
index 00000000..ea84481b
--- /dev/null
+++ b/app/src/main/res/layout/header_snapshots.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_recovery_code_output.xml b/app/src/main/res/layout/list_item_recovery_code_output.xml
index 780f3df7..9d7c0c59 100644
--- a/app/src/main/res/layout/list_item_recovery_code_output.xml
+++ b/app/src/main/res/layout/list_item_recovery_code_output.xml
@@ -34,4 +34,4 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="Test1CanBeLong" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/recovery_code_input.xml b/app/src/main/res/layout/recovery_code_input.xml
index b6d122be..64e4240c 100644
--- a/app/src/main/res/layout/recovery_code_input.xml
+++ b/app/src/main/res/layout/recovery_code_input.xml
@@ -25,7 +25,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput2" />
@@ -47,7 +47,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput4" />
@@ -69,7 +69,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput6" />
@@ -91,7 +91,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput8" />
@@ -113,7 +113,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput10" />
@@ -135,7 +135,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput12" />
@@ -158,7 +158,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput3" />
@@ -180,7 +180,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput5" />
@@ -202,7 +202,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput7" />
@@ -224,7 +224,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput9" />
@@ -246,7 +246,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete"
+ android:inputType="textAutoComplete|textNoSuggestions"
android:nextFocusForward="@+id/wordInput11" />
@@ -268,7 +268,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionDone|flagNoPersonalizedLearning"
- android:inputType="textAutoComplete" />
+ android:inputType="textAutoComplete|textNoSuggestions" />
diff --git a/app/src/main/res/menu/settings_menu.xml b/app/src/main/res/menu/settings_menu.xml
index c5e8b2c9..e9978f91 100644
--- a/app/src/main/res/menu/settings_menu.xml
+++ b/app/src/main/res/menu/settings_menu.xml
@@ -17,6 +17,11 @@
app:showAsAction="never"
tools:visible="true" />
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
index c9ad5f98..be438580 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml
index f4c5000d..bdaf55a4 100644
--- a/app/src/main/res/values/bools.xml
+++ b/app/src/main/res/values/bools.xml
@@ -1,4 +1,4 @@
true
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
index b3771268..56cac504 100644
--- a/app/src/main/res/values/ic_launcher_background.xml
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
#2199CB
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 13e0440a..eaa3c7d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,7 +8,8 @@
Restore backup
- Backup my data
+ App backup
+ Backup my appsBackup locationNoneInternal storage
@@ -28,13 +29,28 @@
Last backup: %1$sExclude appsBackup now
+ Storage backup (experimental)
+ Backup my files
+ Included files and folders
+ NoneRecovery codeVerify 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
-
+ Expert settings
+ Unlimited app quota
+ Do not impose a limitation on the size of app backups.\n\nWarning: This can fill up your storage location quickly. Not needed for most apps.
+
+
Choose where to store backupsWhere 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.
+ Existing backups in this location will be deleted.USB flash driveNeeds to be plugged in%1$s free
@@ -42,7 +58,9 @@
Tap to installTap to set up accountAccount not available. Set one up (or disable passcode).
+ Not installedInitializing backup location…
+ This may take some time…Looking for backups…An error occurred while accessing the backup location.Unable to get the permission to write to the backup location.
@@ -69,8 +87,8 @@
Word 11Word 12You forgot to enter this word.
- Wrong word. Did you mean %1$s or %2$s?
- Your code is invalid. Please check all words and try again!
+ Wrong word.
+ Your code is invalid. Please check all words as well as their position and try again!Recovery code verifiedYour code is correct and will work for restoring your backup.Incorrect recovery code
@@ -80,6 +98,8 @@
Wait one second…Generating a new code will make your existing backups inaccessible. We\'ll try to delete them if possible.\n\nAre you sure you want to do this?New recovery code has been created successfully
+ Re-enter your screen lock
+ Enter your device credentials to continueBackup notification
@@ -125,6 +145,7 @@
Choose a backup to restoreLast backup %1$s · First %2$s.Don\'t restore
+ Skip restoring appsNo backups foundWe could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.An error occurred while loading the backups.
@@ -141,6 +162,13 @@
Restore completeAn error occurred while restoring the backup.Finish
+
+ Skip restoring files
+ Choose a storage backup to restore (experimental)
+ Files are being restored…
+ Your files are being restored in the background. You can start using your phone while this is running.\n\nSome apps (e.g. Signal or WhatsApp) might require files to be fully restored to import a backup. Try to avoid starting those apps before file restore is not complete.
+ Got it
+
WarningYou have chosen internal storage for your backup. This will not be available when your phone is lost or broken.Choose other
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/main/res/xml/settings_expert.xml b/app/src/main/res/xml/settings_expert.xml
new file mode 100644
index 00000000..a257d898
--- /dev/null
+++ b/app/src/main/res/xml/settings_expert.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
index be475cd5..9a9c0f95 100644
--- a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
+++ b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
@@ -44,7 +44,7 @@ fun ByteArray.toHexString(spacer: String = " "): String {
for (b in this) {
str += String.format("%02X$spacer", b)
}
- return str
+ return str.trimEnd()
}
fun ByteArray.toIntString(): String {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/crypto/Bip39ComparisonTest.kt b/app/src/test/java/com/stevesoltys/seedvault/crypto/Bip39ComparisonTest.kt
new file mode 100644
index 00000000..c1e89895
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/crypto/Bip39ComparisonTest.kt
@@ -0,0 +1,55 @@
+package com.stevesoltys.seedvault.crypto
+
+import cash.z.ecc.android.bip39.Mnemonics
+import cash.z.ecc.android.bip39.toSeed
+import com.stevesoltys.seedvault.ui.recoverycode.toMnemonicChars
+import org.bitcoinj.crypto.MnemonicCode
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import kotlin.random.Random
+
+/**
+ * Compares kotlin-bip39 library with bitcoinj library
+ * to ensure that kotlin-bip39 is not malicious and can be upgraded safely.
+ */
+class Bip39ComparisonTest {
+
+ companion object {
+ private const val ITERATIONS = 128
+ private val SEED_SIZE = Mnemonics.WordCount.COUNT_12.bitLength / 8
+
+ @JvmStatic
+ @Suppress("unused")
+ private fun provideEntropy() = ArrayList(ITERATIONS).apply {
+ for (i in 0 until ITERATIONS) {
+ add(Arguments.of(Random.nextBytes(SEED_SIZE)))
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideEntropy")
+ fun compareLibs(entropy: ByteArray) {
+ val actualCodeFromEntropy = Mnemonics.MnemonicCode(entropy)
+ val actualWordsFromEntropy = actualCodeFromEntropy.words.map { it.joinToString("") }
+ val expectedWordsFromEntropy = MnemonicCode.INSTANCE.toMnemonic(entropy)
+ // check that entropy produces the same words
+ assertEquals(expectedWordsFromEntropy, actualWordsFromEntropy)
+
+ val actualCodeFromWords =
+ Mnemonics.MnemonicCode(expectedWordsFromEntropy.toMnemonicChars())
+ // check that both codes are valid
+ MnemonicCode.INSTANCE.check(expectedWordsFromEntropy)
+ actualCodeFromEntropy.validate()
+
+ // check that both codes produce same seed
+ assertArrayEquals(
+ MnemonicCode.toSeed(expectedWordsFromEntropy, ""),
+ actualCodeFromWords.toSeed()
+ )
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/crypto/WordListTest.kt b/app/src/test/java/com/stevesoltys/seedvault/crypto/WordListTest.kt
index 58c771d3..3e54e42e 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/crypto/WordListTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/crypto/WordListTest.kt
@@ -1,11 +1,10 @@
package com.stevesoltys.seedvault.crypto
+import cash.z.ecc.android.bip39.Mnemonics
+import cash.z.ecc.android.bip39.WordList
+import cash.z.ecc.android.bip39.toSeed
import com.stevesoltys.seedvault.toHexString
-import io.github.novacrypto.bip39.JavaxPBKDF2WithHmacSHA512
-import io.github.novacrypto.bip39.MnemonicGenerator
-import io.github.novacrypto.bip39.SeedCalculator
-import io.github.novacrypto.bip39.Words
-import io.github.novacrypto.bip39.wordlists.English
+import com.stevesoltys.seedvault.ui.recoverycode.toMnemonicChars
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -2067,62 +2066,197 @@ class WordListTest {
@Test
fun `word list of library did not change`() {
+ val libWords = WordList().words
for (i in words.indices) {
- assertEquals(words[i], English.INSTANCE.getWord(i))
+ assertEquals(words[i], libWords[i])
}
}
@Test
- fun `test createMnemonic`() {
- val entropy = ByteArray(Words.TWELVE.byteLength())
+ fun `test creating MnemonicCode from entropy`() {
+ val entropy = ByteArray(Mnemonics.WordCount.COUNT_12.bitLength / 8)
Random.nextBytes(entropy)
- val list = ArrayList(12)
- MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
- if (it != " ") list.add(it.toString())
- }
- assertEquals(12, list.size)
- for (word in list) {
- assertTrue(word in words)
+ val code = Mnemonics.MnemonicCode(entropy)
+ assertEquals(12, code.words.size)
+ for (word in code) {
+ assertTrue(word in words, "$word unknown")
}
}
@Test
- @Suppress("MaxLineLength")
- fun `12 words generate expected seed`() {
+ fun `12 not validating words generate seed that novacrypt generated`() {
assertEquals(
"64AA8C388EC0F3A13C7E51653BC766E30668D30952AB34381C4B174BF3278774" +
"B4EE43D0BA08BCBCE0D0B806DEB7AA364A83525C34847078B2A8002A3E116066",
- SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(
- "write wrong yard year yellow you young youth zebra zero zone zoo", ""
- ).toHexString("")
+ Mnemonics.MnemonicCode(
+ "write wrong yard year yellow you young youth zebra zero zone zoo"
+ ).toSeed(validate = false).toHexString("")
)
assertEquals(
"E911FAA42F389AA9F6D5A40B2ECB876B06D6D1FFBD5885C54720398EB11918CA" +
"B8F7BAD70FD5BE39BEB4EB065610700D1CFF1D4BFAA26F998357E15E79002779",
- SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(
- "matrix lava they brand negative spray floor gym purity picture ritual disorder", ""
- ).toHexString("")
+ Mnemonics.MnemonicCode(
+ "matrix lava they brand negative spray floor gym purity picture ritual disorder"
+ ).toSeed(validate = false).toHexString("")
)
assertEquals(
"DDB26091680CF30D0DC615546E4612327DB287B6B2B8B8947A3E12580315D38C" +
"3BF7DD0EB4E9E50B10A41925332E0C8ED43C80DBA29281EF331A1DFA858BF1C9",
- SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(
- "middle rack south alert ribbon tube hope involve defy oxygen gloom rabbit", ""
- ).toHexString("")
+ Mnemonics.MnemonicCode(
+ "middle rack south alert ribbon tube hope involve defy oxygen gloom rabbit"
+ ).toSeed(validate = false).toHexString("")
)
assertEquals(
"4815B580D0DCDA08334C92B3CB9A8436CD581C55841FB2794FB1E3D6E389F447" +
"C8C6520B2FE567720950F5B39BE7EC42C0BC98D3C63F8FEF642B5BD3EE4CDD7B",
- SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(
- "interest mask trial hold foot segment fade page monitor apple garden shuffle", ""
- ).toHexString("")
+ Mnemonics.MnemonicCode(
+ "interest mask trial hold foot segment fade page monitor apple garden shuffle"
+ ).toSeed(validate = false).toHexString("")
)
assertEquals(
"FF462543D8FB9DAE6C17FA7BA047238664207FCC797D6688E10DD1B3CFD183D4" +
"928AD088E8287B69BABCAEB0F87A2DFF2ADD49A7FDB7EB2554D7344F09C41A76",
- SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(
- "palace glory gospel garment obscure person edge total hunt fix setup uphold\n", ""
- ).toHexString("")
+ Mnemonics.MnemonicCode(
+ "palace glory gospel garment obscure person edge total hunt fix setup uphold\n"
+ ).toSeed(validate = false).toHexString("")
+ )
+ }
+
+ @Test
+ fun `12 valid words generate seed that novacrypt generated`() {
+ assertEquals(
+ "C6F9762718449C9D0794FEC140D2C8D4E23FF8E3701D64C03DDD13C69BC73E48" +
+ "6AB89AB2C7C9BEA43F4AF839F2078595851D5D48FEC6A9FC6C25F399DBB909F9",
+ Mnemonics.MnemonicCode(
+ "script vault basic album cotton car entire jaguar correct anger select flower"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "13C5188428B1DF8A5333E60BA7EC47F7E75585315C73BD19812D3591C5F4C52B" +
+ "B2FC1FF40B1942E2A1EF9F34F586114ED37D46A5A3907A43B317E937C1D9D2CD",
+ Mnemonics.MnemonicCode(
+ "drastic toy fatal goose treat saddle chalk fame dismiss employ super behind"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "40B41BB22AC3A507F26A78E027A3B3C5C8F45FF0F5593D82762C74AE69FA548B" +
+ "A72C0CED31DED6211884B412E7B80F932F9830FA7A67CDB5B28604213DE6599C",
+ Mnemonics.MnemonicCode(
+ "nation infant heart virus argue two vivid slam lend decorate turn wish"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "B6D755172B6E9353A25EB3559336C17A8619F3EBE55E8A9A74A44E1AB88EF5E2" +
+ "C6E12FE132E42A55CC3F8F9224E6A0ABC9C3FF4EB9523A4E9750CDAAEFBA6282",
+ Mnemonics.MnemonicCode(
+ "elbow boy powder robot eagle rival neutral pigeon oil shrimp demand health"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "3EDB1292B4D124426201AC523FCC2572184E0B63667DA7DF105AD8FCCD16C074" +
+ "C6DAF9C7D644B4B48AF75185D21B9E7D778FFE55F836C539581DEBB98C331526",
+ Mnemonics.MnemonicCode(
+ "build setup screen solution prepare spice organ ten loud seek ask attract"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "65986351CD054822B40E417855AC2B5651C5F87892F17ED2A984F6B59DD5FB4E" +
+ "6A4568ABF7E06D93CBCC69BB68F2625E3E8AF2751106380922D49C0D0D0B456B",
+ Mnemonics.MnemonicCode(
+ "unhappy welcome pizza inflict inherit village minimum orient cheap swear grunt giraffe" // ktlint-disable max-line-length
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "639034B381740A9FA5B8A84715CF18B21EBD343DD91F7B6124A0EFC32A636619" +
+ "49B02A7810B1A99D8E8CC4CD7D046CE59EAAADBB52DDC0B5036EFED007E1CFF6",
+ Mnemonics.MnemonicCode(
+ "rather suit pluck afford avocado diary swap library earn song rival fiber"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "43E1417221BFB40851DE286B543B51DEE9C01D239B2C2E8A355D45B3DF95DFAA" +
+ "C8DBCAEF1D864D91759A07057DBDB891900D583CAB09BD0655493912108AE65A",
+ Mnemonics.MnemonicCode(
+ "toss note family morning silk edge high error appear tilt almost myth"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "14084AAF9CFCAC386D4CE5B9140BEADBF727B1B09786A67A574B668A1A4AE0A3" +
+ "21B8D4E7BC005980B088A160B6EC08A1CB892C2090C58D95A7C6AAD16C14EE1E",
+ Mnemonics.MnemonicCode(
+ "parrot burden release bronze section fantasy ridge blood direct physical spoil asthma" // ktlint-disable max-line-length
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "E11E8737327EE6A640761B3888C349D829A60FEAEFB7914D2AE1616F0AC45B9A" +
+ "322F41D0030C89E209300FA25615FE6B5BDEF73F3E5CE21167685E8A27EE0790",
+ Mnemonics.MnemonicCode(
+ "version deliver worry sick flee submit pledge adapt night swear glare adult"
+ ).toSeed().toHexString("")
+ )
+ assertEquals(
+ "02652896F67695C03F379A354685A8A0B92D0F303F77461476E80BB594EAD84B" +
+ "D00B2943C2229ED843C65F6C53A376005871FF74F834E6B6E3B57FFD83D3FB12",
+ Mnemonics.MnemonicCode(
+ "exercise curtain initial model travel client twist neutral peace unfold start shell" // ktlint-disable max-line-length
+ ).toSeed().toHexString("")
+ )
+ }
+
+ @Test
+ fun `entropy generates same words that novacrypt generated`() {
+ assertEquals(
+ "B8 3F 0D 49 7F 2A F4 69 13 71 47 D5 54 9D 17 0B",
+ Mnemonics.MnemonicCode(
+ "return wear false wrestle quantum cruel evidence cigar stem pilot easy blood"
+ ).toEntropy().toHexString()
+ )
+ assertEquals(
+ "AA 8E 3F 1C EF 60 20 D7 D2 BB FD A0 AA AD 69 09",
+ Mnemonics.MnemonicCode(
+ "pride impose shrimp tell acoustic hip enough leisure pass fever fog basket"
+ ).toEntropy().toHexString()
+ )
+ assertEquals(
+ "CE E8 17 7E AB 9E 49 8A 0B 32 3C 97 94 07 0C 32",
+ Mnemonics.MnemonicCode(
+ "solve doll text fire tonight shallow coast elegant nurse parent seek grass"
+ ).toEntropy().toHexString()
+ )
+ assertEquals(
+ "AA 27 4B D8 7B 0D 18 EB 5D 08 BD 11 73 36 C1 06",
+ Mnemonics.MnemonicCode(
+ "pretty demise voyage voyage spice interest injury bless badge often raccoon artefact" // ktlint-disable max-line-length
+ ).toEntropy().toHexString()
+ )
+ assertEquals(
+ "16 5F A7 40 26 E9 51 70 1B 7A 5C D2 AB CD 73 7E",
+ Mnemonics.MnemonicCode(
+ "bind wood source evidence never retreat hospital entire sport fury fresh woman"
+ ).toEntropy().toHexString()
+ )
+ }
+
+ @Test
+ fun `test create MnemonicCode from List of CharSequence`() {
+ assertEquals(
+ "B8 3F 0D 49 7F 2A F4 69 13 71 47 D5 54 9D 17 0B",
+ Mnemonics.MnemonicCode(
+ listOf(
+ "return",
+ "wear",
+ "false",
+ "wrestle",
+ "quantum",
+ "cruel",
+ "evidence",
+ "cigar",
+ "stem",
+ "pilot",
+ "easy",
+ "blood"
+ ).toMnemonicChars()
+ ).toEntropy().toHexString()
)
}
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/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
index b6c28f53..e7e07b4a 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
@@ -65,10 +65,22 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val backupPlugin = mockk()
private val kvBackupPlugin = mockk()
- private val kvBackup =
- KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl, notificationManager)
+ private val kvBackup = KVBackup(
+ plugin = kvBackupPlugin,
+ settingsManager = settingsManager,
+ inputFactory = inputFactory,
+ headerWriter = headerWriter,
+ crypto = cryptoImpl,
+ nm = notificationManager
+ )
private val fullBackupPlugin = mockk()
- private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
+ private val fullBackup = FullBackup(
+ plugin = fullBackupPlugin,
+ settingsManager = settingsManager,
+ inputFactory = inputFactory,
+ headerWriter = headerWriter,
+ crypto = cryptoImpl
+ )
private val apkBackup = mockk()
private val packageService: PackageService = mockk()
private val backup = BackupCoordinator(
@@ -277,6 +289,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val bInputStream = ByteArrayInputStream(appData)
coEvery { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
+ every { settingsManager.isQuotaUnlimited() } returns false
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
coEvery {
apkBackup.backupApkIfNecessary(
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
index 1328741a..c5c49d3f 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
@@ -22,7 +22,7 @@ import kotlin.random.Random
internal class FullBackupTest : BackupTest() {
private val plugin = mockk()
- private val backup = FullBackup(plugin, inputFactory, headerWriter, crypto)
+ private val backup = FullBackup(plugin, settingsManager, inputFactory, headerWriter, crypto)
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) }
@@ -35,11 +35,19 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `checkFullBackupSize exceeds quota`() {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(quota + 1))
}
+ @Test
+ fun `checkFullBackupSize does not exceed quota when unlimited`() {
+ every { settingsManager.isQuotaUnlimited() } returns true
+
+ assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota + 1))
+ }
+
@Test
fun `checkFullBackupSize for no data`() {
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
@@ -52,6 +60,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `checkFullBackupSize accepts min data`() {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(1))
@@ -59,6 +68,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `checkFullBackupSize accepts max data`() {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota))
@@ -77,6 +87,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData first call over quota`() = runBlocking {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = (quota + 1).toInt()
@@ -93,6 +104,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData second call over quota`() = runBlocking {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = quota.toInt()
@@ -115,6 +127,7 @@ internal class FullBackupTest : BackupTest() {
fun `sendBackupData throws exception when reading from InputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } throws IOException()
expectClearState()
@@ -131,6 +144,7 @@ internal class FullBackupTest : BackupTest() {
fun `sendBackupData throws exception when getting outputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
coEvery { plugin.getOutputStream(packageInfo) } throws IOException()
expectClearState()
@@ -147,6 +161,7 @@ internal class FullBackupTest : BackupTest() {
fun `sendBackupData throws exception when writing header`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
coEvery { plugin.getOutputStream(packageInfo) } returns outputStream
every { inputFactory.getInputStream(data) } returns inputStream
@@ -166,6 +181,7 @@ internal class FullBackupTest : BackupTest() {
runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
+ every { settingsManager.isQuotaUnlimited() } returns false
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } returns bytes.size
every { crypto.encryptSegment(outputStream, any()) } throws IOException()
@@ -181,6 +197,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData runs ok`() = runBlocking {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = (quota / 2).toInt()
@@ -234,6 +251,7 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `clearState throws exception when flushing OutputStream`() = runBlocking {
+ every { settingsManager.isQuotaUnlimited() } returns false
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = 42
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
index 1a7f8d80..f7670825 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
@@ -36,7 +36,14 @@ internal class KVBackupTest : BackupTest() {
private val dataInput = mockk()
private val notificationManager = mockk()
- private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto, notificationManager)
+ private val backup = KVBackup(
+ plugin = plugin,
+ settingsManager = settingsManager,
+ inputFactory = inputFactory,
+ headerWriter = headerWriter,
+ crypto = crypto,
+ nm = notificationManager
+ )
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
diff --git a/build.gradle b/build.gradle
index 98c5c515..3acb684f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,22 +1,33 @@
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 {
+ mavenCentral()
jcenter()
google()
}
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.2.1'
}
}
+ext {
+ buildToolsVersion = '30.0.2'
+ compileSdkVersion = 30
+ minSdkVersion = 29
+ targetSdkVersion = 30
+}
+
+apply from: 'gradle/dependencies.gradle'
+
allprojects {
repositories {
mavenCentral()
diff --git a/contactsbackup/.gitignore b/contactsbackup/.gitignore
index 42afabfd..796b96d1 100644
--- a/contactsbackup/.gitignore
+++ b/contactsbackup/.gitignore
@@ -1 +1 @@
-/build
\ No newline at end of file
+/build
diff --git a/contactsbackup/Android.bp b/contactsbackup/Android.bp
index 9a4e2e60..e0d60206 100644
--- a/contactsbackup/Android.bp
+++ b/contactsbackup/Android.bp
@@ -13,11 +13,13 @@ android_app {
required: [
"default-permissions_org.calyxos.backup.contacts",
],
+ product_specific: true,
sdk_version: "current",
}
prebuilt_etc {
name: "default-permissions_org.calyxos.backup.contacts",
+ product_specific: true,
sub_dir: "default-permissions",
src: "default-permissions_org.calyxos.backup.contacts.xml",
filename_from_src: true,
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/contactsbackup/src/main/AndroidManifest.xml b/contactsbackup/src/main/AndroidManifest.xml
index 59480435..fa6675c1 100644
--- a/contactsbackup/src/main/AndroidManifest.xml
+++ b/contactsbackup/src/main/AndroidManifest.xml
@@ -2,8 +2,8 @@
+ android:versionCode="30000221"
+ android:versionName="11-2.2">
+
+
+
+
+
+
+
+
+
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..777247b4
--- /dev/null
+++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt
@@ -0,0 +1,141 @@
+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"))
+ // FIXME: This might get killed if we navigate away from the activity.
+ // A foreground service would avoid that.
+ 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) {
+ viewModelScope.launch(Dispatchers.IO) {
+ storageBackup.deleteAllSnapshots()
+ storageBackup.clearCache()
+ }
+ }
+ 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_USER_ID, item.storedSnapshot.userId)
+// 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(item.storedSnapshot, 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