Merge pull request #321 from chirayudesai/android11-2.2-merge

Merge master into android11 (11-2.2)
This commit is contained in:
Torsten Grote 2021-09-29 11:38:28 -03:00 committed by GitHub
commit 1a48d339d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
255 changed files with 11579 additions and 475 deletions

View file

@ -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

View file

@ -32,4 +32,4 @@ jobs:
java-version: 11
- name: Build
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck

9
.gitignore vendored
View file

@ -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

View file

@ -4,15 +4,6 @@
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
@ -146,4 +137,4 @@
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
</component>

View file

@ -2,4 +2,4 @@
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
</component>

View file

@ -0,0 +1,14 @@
<component name="ProjectDictionaryState">
<dictionary name="user">
<words>
<w>apk</w>
<w>chunker</w>
<w>ejectable</w>
<w>hasher</w>
<w>hkdf</w>
<w>restorable</w>
<w>seedvault</w>
<w>snowden</w>
</words>
</dictionary>
</component>

View file

@ -7,4 +7,4 @@
<inspection_tool class="LongLine" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="RedundantSemicolon" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
</component>

View file

@ -3,10 +3,11 @@
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>
</project>

View file

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All unit tests" type="CompoundRunConfigurationType">
<toRun name="Unit tests: storage/lib" type="AndroidJUnit" />
<toRun name="Unit tests: app" type="AndroidJUnit" />
<toRun name="Unit tests: contactsbackup" type="AndroidJUnit" />
<method v="2" />
</configuration>
</component>

View file

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
<configuration default="false" name="Instrumentation tests: app" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
<module name="seedvault.app" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
@ -48,4 +48,4 @@
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>
</component>

View file

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
<configuration default="false" name="Unit tests: app" type="AndroidJUnit" factoryName="Android JUnit">
<module name="seedvault.app" />
<useClassPathOnly />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
@ -14,4 +14,4 @@
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>
</component>

View file

@ -0,0 +1,15 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit tests: contactsbackup" type="AndroidJUnit" factoryName="Android JUnit">
<module name="seedvault.contactsbackup" />
<useClassPathOnly />
<option name="MAIN_CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="directory" />
<option name="PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
<dir value="$PROJECT_DIR$/contactsbackup/src/test/java" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -0,0 +1,15 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit tests: storage/lib" type="AndroidJUnit" factoryName="Android JUnit">
<module name="seedvault.storage.lib" />
<useClassPathOnly />
<option name="MAIN_CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="directory" />
<option name="PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
<dir value="$PROJECT_DIR$/storage/lib/src/test/java" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -0,0 +1,21 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="seedvault:storage:lib [assembleRelease]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/storage/lib" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="assembleRelease" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
<method v="2" />
</configuration>
</component>

View file

@ -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",

View file

@ -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

View file

@ -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.
limitations under the License.

View file

@ -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.

View file

@ -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) {

View file

@ -2,8 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.stevesoltys.seedvault"
android:versionCode="30000021"
android:versionName="11-1.2">
android:versionCode="30000221"
android:versionName="11-2.2">
<!--
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
The version name is the targeted Android version followed by - and our own version name.
@ -47,6 +47,19 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!-- Used to authenticate saving a new recovery code -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Permission used to open settings -->
<permission
android:name="com.stevesoltys.seedvault.OPEN_SETTINGS"
android:protectionLevel="system|signature" />
<!-- Permission used to open backup gui -->
<permission
android:name="com.stevesoltys.seedvault.RESTORE_BACKUP"
android:protectionLevel="system|signature" />
<application
android:name=".App"
android:allowBackup="false"
@ -59,6 +72,7 @@
<activity
android:name=".settings.SettingsActivity"
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
android:exported="true" />
<activity
@ -77,6 +91,7 @@
<activity
android:name=".restore.RestoreActivity"
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP"
android:exported="true"
android:label="@string/restore_title"
android:theme="@style/AppTheme.NoActionBar">
@ -113,5 +128,33 @@
</intent-filter>
</receiver>
<receiver
android:name=".SecretCodeReceiver">
<intent-filter>
<action android:name="android.telephony.action.SECRET_CODE" />
<!-- *#*#RESTORE#*#* -->
<data android:scheme="android_secret_code" android:host="7378673" />
</intent-filter>
</receiver>
<!-- Used to start actual BackupService depending on scheduling criteria -->
<service
android:name=".storage.StorageBackupJobService"
android:exported="false"
android:label="BackupJobService"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- Does the actual backup work as a foreground service -->
<service
android:name=".storage.StorageBackupService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:label="BackupService" />
<!-- Does restore as a foreground service -->
<service
android:name=".storage.StorageRestoreService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:label="RestoreService" />
</application>
</manifest>

View file

@ -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> { 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
)
)

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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()

View file

@ -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,

View file

@ -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()
}
})

View file

@ -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
}
}

View file

@ -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

View file

@ -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() {

View file

@ -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<RestoreBackupResult>()
internal val restoreBackupResult: LiveData<RestoreBackupResult> 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
}

View file

@ -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) {

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode
private val _filesSummary = MutableLiveData<String>()
internal val filesSummary: LiveData<String> = _filesSummary
private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, 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)
}
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) }
single { StorageBackup(get(), get()) }
}

View file

@ -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")
}
}

View file

@ -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()

View file

@ -21,6 +21,7 @@ val backupModule = module {
single {
KVBackup(
plugin = get<BackupPlugin>().kvBackupPlugin,
settingsManager = get(),
inputFactory = get(),
headerWriter = get(),
crypto = get(),
@ -30,6 +31,7 @@ val backupModule = module {
single {
FullBackup(
plugin = get<BackupPlugin>().fullBackupPlugin,
settingsManager = get(),
inputFactory = get(),
headerWriter = get(),
crypto = get()

View file

@ -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.
*/

View file

@ -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,

View file

@ -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.")

View file

@ -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

View file

@ -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<FileSelectionViewModel>()
private val settingsViewModel by sharedViewModel<SettingsViewModel>()
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()
}
}

View file

@ -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() }
}
}

View file

@ -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)

View file

@ -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<CharSequence>) :
class RecoveryCodeAdapter(private val items: List<CharArray>) :
Adapter<RecoveryCodeViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecoveryCodeViewHolder {
@ -30,9 +30,9 @@ class RecoveryCodeViewHolder(v: View) : RecyclerView.ViewHolder(v) {
private val num = v.findViewById<TextView>(R.id.num)
private val word = v.findViewById<TextView>(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)
}
}

View file

@ -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<String>(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<String> {
val adapter = ArrayAdapter<String>(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<CharSequence> = ArrayList<String>(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<CharSequence>) {
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<CharSequence>): Boolean {
@ -153,10 +190,11 @@ class RecoveryCodeInputFragment : Fragment() {
return true
}
private fun showWrongWordError(input: List<CharSequence>, e: WordNotFoundException) {
val i = input.indexOf(e.word)
private fun showWrongWordError(input: List<CharSequence>) {
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]))
}
}

View file

@ -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)

View file

@ -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<CharSequence> by lazy {
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
val entropy = ByteArray(Words.TWELVE.byteLength())
internal val wordList: List<CharArray> 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<Boolean>()
@ -57,36 +56,72 @@ class RecoveryCodeViewModel(
internal var isRestore: Boolean = false
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
fun validateAndContinue(input: List<CharSequence>, forVerifyingNewCode: Boolean) {
@Throws(InvalidWordException::class, ChecksumException::class)
fun validateCode(input: List<CharSequence>): 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<CharSequence>) {
// 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<CharSequence>) {
// 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<CharSequence>.toMnemonicChars(): CharArray {
return joinToString(" ").toCharArray()
}

View file

@ -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()

View file

@ -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<View>(R.id.patienceView).visibility = GONE
progressBar.visibility = INVISIBLE
errorView.text = errorMsg
errorView.visibility = VISIBLE

View file

@ -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)

View file

@ -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
}

View file

@ -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 {

View file

@ -7,4 +7,4 @@
<path
android:fillColor="#FF000000"
android:pathData="M19,20H6C2.71,20 0,17.29 0,14C0,10.9 2.34,8.36 5.35,8.03C6.6,5.64 9.11,4 12,4C15.64,4 18.67,6.59 19.35,10.03C21.95,10.22 24,12.36 24,15C24,17.74 21.74,20 19,20M11,15V17H13V15H11M11,13H13V8H11V13Z" />
</vector>
</vector>

View file

@ -2,4 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_default_background" />
<foreground android:drawable="@drawable/ic_launcher_default_foreground" />
</adaptive-icon>
</adaptive-icon>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM19,11h-4v4h-2v-4L9,11L9,9h4L13,5h2v4h4v2z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,12v7L5,19v-7L3,12v7c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2zM13,12.67l2.59,-2.58L17,11.5l-5,5 -5,-5 1.41,-1.41L11,12.67L11,3h2z" />
</vector>

View file

@ -1,7 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -2,4 +2,4 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent" />

View file

@ -2,4 +2,4 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent" />

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/skipView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:padding="16dp"
android:text="@string/restore_storage_skip"
android:textColor="?android:colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</ScrollView>

View file

@ -83,4 +83,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordList" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="16dp"
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_cloud_restore"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center"
android:text="@string/restore_storage_in_progress_title"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/infoView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_storage_in_progress_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
<Button
android:id="@+id/button"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_storage_got_it"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/infoView"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />
<TextView
android:id="@+id/backView"
android:id="@+id/skipView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_back"
android:background="?android:selectableItemBackground"
android:padding="16dp"
android:text="@string/restore_skip"
android:textColor="?android:colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View file

@ -29,6 +29,17 @@
app:layout_constraintTop_toBottomOf="@+id/imageView"
tools:text="@string/storage_check_fragment_backup_title" />
<TextView
android:id="@+id/patienceView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center_horizontal"
android:text="@string/storage_check_fragment_patience"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:progressBarStyleLarge"
@ -37,10 +48,10 @@
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/backButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
app:layout_constraintTop_toBottomOf="@+id/patienceView"
app:layout_constraintVertical_bias="0.0" />
<TextView
@ -56,7 +67,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
app:layout_constraintTop_toBottomOf="@+id/patienceView"
app:layout_constraintVertical_bias="0.0"
tools:text="@string/storage_check_fragment_backup_error"
tools:visibility="visible" />

View file

@ -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)" />
<ImageView
android:id="@+id/warningIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:src="@drawable/ic_warning"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/warningText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
app:layout_constraintTop_toTopOf="@+id/warningText"
tools:ignore="ContentDescription"
tools:visibility="visible" />
@ -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"

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="16dp"
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_cloud_download"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center"
android:text="@string/restore_storage_choose_snapshot"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -34,4 +34,4 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="Test1CanBeLong" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -268,7 +268,7 @@
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionDone|flagNoPersonalizedLearning"
android:inputType="textAutoComplete" />
android:inputType="textAutoComplete|textNoSuggestions" />
</com.google.android.material.textfield.TextInputLayout>

View file

@ -17,6 +17,11 @@
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/action_settings_expert"
android:title="@string/settings_expert_title"
app:showAsAction="never" />
<item
android:id="@+id/action_about"
android:title="@string/about_title"

View file

@ -2,4 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
</adaptive-icon>

View file

@ -2,4 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
</adaptive-icon>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="isLight">true</bool>
</resources>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2199CB</color>
</resources>
</resources>

View file

@ -8,7 +8,8 @@
<string name="restore_backup_button">Restore backup</string>
<!-- Settings -->
<string name="settings_backup">Backup my data</string>
<string name="settings_category_apps">App backup</string>
<string name="settings_backup">Backup my apps</string>
<string name="settings_backup_location">Backup location</string>
<string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal storage</string>
@ -28,13 +29,28 @@
<string name="settings_backup_status_summary">Last backup: %1$s</string>
<string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</string>
<string name="settings_category_storage">Storage backup (experimental)</string>
<string name="settings_backup_storage_title">Backup my files</string>
<string name="settings_backup_files_title">Included files and folders</string>
<string name="settings_backup_files_summary">None</string>
<string name="settings_backup_recovery_code">Recovery code</string>
<string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</string>
<string name="settings_backup_storage_dialog_title">Experimental feature</string>
<string name="settings_backup_storage_dialog_message">Backing up files is still experimental and might not work. Do not rely on it for important data.</string>
<string name="settings_backup_storage_dialog_ok">Enable anyway</string>
<string name="settings_backup_storage_code_dialog_title">Recovery code verification required</string>
<string name="settings_backup_storage_code_dialog_message">To enable storage backup, you need to first verify your recovery code or generate a new one.</string>
<string name="settings_backup_storage_code_dialog_ok">Verify code</string>
<!-- Storage -->
<string name="settings_expert_title">Expert settings</string>
<string name="settings_expert_quota_title">Unlimited app quota</string>
<string name="settings_expert_quota_summary">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.</string>
<!-- Storage Location -->
<string name="storage_fragment_backup_title">Choose where to store backups</string>
<string name="storage_fragment_restore_title">Where to find your backups?</string>
<string name="storage_fragment_warning">People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.</string>
<string name="storage_fragment_warning_delete">Existing backups in this location will be deleted.</string>
<string name="storage_fake_drive_title">USB flash drive</string>
<string name="storage_fake_drive_summary">Needs to be plugged in</string>
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> free</string>
@ -42,7 +58,9 @@
<string name="storage_fake_nextcloud_summary">Tap to install</string>
<string name="storage_fake_nextcloud_summary_installed">Tap to set up account</string>
<string name="storage_fake_nextcloud_summary_unavailable">Account not available. Set one up (or disable passcode).</string>
<string name="storage_fake_nextcloud_summary_unavailable_market">Not installed</string>
<string name="storage_check_fragment_backup_title">Initializing backup location…</string>
<string name="storage_check_fragment_patience">This may take some time…</string>
<string name="storage_check_fragment_restore_title">Looking for backups…</string>
<string name="storage_check_fragment_backup_error">An error occurred while accessing the backup location.</string>
<string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string>
@ -69,8 +87,8 @@
<string name="recovery_code_input_hint_11">Word 11</string>
<string name="recovery_code_input_hint_12">Word 12</string>
<string name="recovery_code_error_empty_word">You forgot to enter this word.</string>
<string name="recovery_code_error_invalid_word">Wrong word. Did you mean %1$s or %2$s?</string>
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
<string name="recovery_code_error_invalid_word">Wrong word.</string>
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words as well as their position and try again!</string>
<string name="recovery_code_verification_ok_title">Recovery code verified</string>
<string name="recovery_code_verification_ok_message">Your code is correct and will work for restoring your backup.</string>
<string name="recovery_code_verification_error_title">Incorrect recovery code</string>
@ -80,6 +98,8 @@
<string name="recovery_code_verification_new_dialog_title">Wait one second…</string>
<string name="recovery_code_verification_new_dialog_message">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?</string>
<string name="recovery_code_recreated">New recovery code has been created successfully</string>
<string name="recovery_code_auth_title">Re-enter your screen lock</string>
<string name="recovery_code_auth_description">Enter your device credentials to continue</string>
<!-- Notification -->
<string name="notification_channel_title">Backup notification</string>
@ -125,6 +145,7 @@
<string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_restore_set_times">Last backup %1$s · First %2$s.</string>
<string name="restore_back">Don\'t restore</string>
<string name="restore_skip">Skip restoring apps</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string>
@ -141,6 +162,13 @@
<string name="restore_finished_success">Restore complete</string>
<string name="restore_finished_error">An error occurred while restoring the backup.</string>
<string name="restore_finished_button">Finish</string>
<string name="restore_storage_skip">Skip restoring files</string>
<string name="restore_storage_choose_snapshot">Choose a storage backup to restore (experimental)</string>
<string name="restore_storage_in_progress_title">Files are being restored…</string>
<string name="restore_storage_in_progress_info">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.</string>
<string name="restore_storage_got_it">Got it</string>
<string name="storage_internal_warning_title">Warning</string>
<string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>
<string name="storage_internal_warning_choose_other">Choose other</string>

View file

@ -1,53 +1,75 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.preference.SwitchPreferenceCompat
app:icon="@drawable/ic_cloud_upload"
app:key="backup"
app:persistent="false"
app:title="@string/settings_backup" />
<androidx.preference.Preference
app:allowDividerAbove="true"
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
app:icon="@drawable/ic_apps"
app:key="backup_status"
app:title="@string/settings_backup_status_title"
tools:summary="Last backup: Never" />
<androidx.preference.Preference
app:dependency="backup"
app:icon="@drawable/ic_storage"
app:icon="@drawable/ic_save_alt"
app:key="backup_location"
app:summary="@string/settings_backup_location_none"
app:title="@string/settings_backup_location" />
<androidx.preference.SwitchPreferenceCompat
app:dependency="backup"
app:key="auto_restore"
app:persistent="false"
app:summary="@string/settings_auto_restore_summary"
app:title="@string/settings_auto_restore_title" />
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="backup"
app:key="backup_apk"
app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" />
<androidx.preference.Preference
app:dependency="backup"
app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment"
app:icon="@drawable/ic_vpn_key"
app:key="backup_recovery_code"
app:summary="@string/settings_backup_recovery_code_summary"
app:title="@string/settings_backup_recovery_code" />
<PreferenceCategory android:title="@string/settings_category_apps">
<androidx.preference.SwitchPreferenceCompat
app:allowDividerBelow="true"
app:icon="@drawable/ic_cloud_upload"
app:key="backup"
app:persistent="false"
app:title="@string/settings_backup" />
<androidx.preference.Preference
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
app:icon="@drawable/ic_apps"
app:key="backup_status"
app:title="@string/settings_backup_status_title"
tools:summary="Last backup: Never" />
<androidx.preference.SwitchPreferenceCompat
app:dependency="backup"
app:key="auto_restore"
app:persistent="false"
app:summary="@string/settings_auto_restore_summary"
app:title="@string/settings_auto_restore_title"
tools:defaultValue="true" />
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="backup"
app:key="backup_apk"
app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_storage">
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="false"
app:icon="@drawable/ic_storage"
app:key="backup_storage"
app:title="@string/settings_backup_storage_title" />
<androidx.preference.Preference
android:dependency="backup_storage"
app:dependency="backup_storage"
app:fragment="com.stevesoltys.seedvault.ui.files.FileSelectionFragment"
app:icon="@drawable/ic_library_add"
app:key="backup_files"
app:summary="@string/settings_backup_files_summary"
app:title="@string/settings_backup_files_title" />
</PreferenceCategory>
<androidx.preference.Preference
app:allowDividerAbove="true"
app:allowDividerBelow="false"
app:dependency="backup"
app:icon="@drawable/ic_info_outline"
app:selectable="false"
app:summary="@string/settings_info" />

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="unlimited_quota"
android:summary="@string/settings_expert_quota_summary"
android:title="@string/settings_expert_quota_title" />
</PreferenceScreen>

View file

@ -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 {

View file

@ -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<Arguments>(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()
)
}
}

View file

@ -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<String>(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<CharSequence>(
"return",
"wear",
"false",
"wrestle",
"quantum",
"cruel",
"evidence",
"cigar",
"stem",
"pilot",
"easy",
"blood"
).toMnemonicChars()
).toEntropy().toHexString()
)
}

View file

@ -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/" +

View file

@ -65,10 +65,22 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>()
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<FullBackupPlugin>()
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<ApkBackup>()
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(

View file

@ -22,7 +22,7 @@ import kotlin.random.Random
internal class FullBackupTest : BackupTest() {
private val plugin = mockk<FullBackupPlugin>()
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

View file

@ -36,7 +36,14 @@ internal class KVBackupTest : BackupTest() {
private val dataInput = mockk<BackupDataInput>()
private val notificationManager = mockk<BackupNotificationManager>()
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))

View file

@ -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()

View file

@ -1 +1 @@
/build
/build

View file

@ -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,

View file

@ -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"
}

View file

@ -2,8 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.calyxos.backup.contacts"
android:versionCode="30000021"
android:versionName="11-1.2">
android:versionCode="30000221"
android:versionName="11-2.2">
<!--
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
The version name is the targeted Android version followed by - and our own version name.

View file

@ -1,3 +1,5 @@
org.gradle.jvmargs=-Xmx1g
org.gradle.configureondemand=true
android.useAndroidX=true
android.enableJetifier=false
kotlin.code.style=official

View file

@ -1,130 +1,101 @@
ext {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2713
ext.room_version = "2.3.0-alpha02"
// http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
ext.protobuf_version = "3.9.1"
junit4_version = "4.13.2"
junit5_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
mockk_version = "1.10.2" // higher versions get us a kotlin version conflict
espresso_version = "3.3.0"
}
// To produce these binaries, in latest AOSP source tree, run
// $ m
def aospDeps = fileTree(include: [
// For more information about this module:
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
// framework_intermediates/classes-header.jar works for gradle build as well,
// but not unit tests, so we use the actual classes (without updatable modules).
//
// out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
'android.jar',
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
'libcore.jar'
], dir: 'libs')
ext.aosp_libs = fileTree(include: [
// For more information about this module:
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
// framework_intermediates/classes-header.jar works for gradle build as well,
// but not unit tests, so we use the actual classes (without updatable modules).
//
// out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
'android.jar',
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
'libcore.jar',
], dir: "$projectDir/app/libs")
dependencies {
compileOnly aospDeps
ext.kotlin_libs = [
std: [
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib') {
version { strictly "$aosp_kotlin_version" }
},
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
version { strictly "$aosp_kotlin_version" }
},
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-common') {
version { strictly "$aosp_kotlin_version" }
},
],
coroutines: [
dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
version { strictly '1.3.0' }
},
dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
version { strictly '1.3.0' }
},
],
]
/**
* Dependencies in AOSP
*
* We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
* with the AOSP build system and gradle builds are just for more pleasant development.
* Using the AOSP versions in gradle builds allows us to spot issues early on.
*/
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
version { strictly "$kotlin_version" }
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-common') {
version { strictly "$kotlin_version" }
}
implementation('org.jetbrains.kotlin:kotlin-stdlib') {
version { strictly "$kotlin_version" }
}
// These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
version { strictly '1.3.0' }
}
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
version { strictly '1.3.0' }
}
implementation('androidx.core:core-ktx') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#610
ext.std_libs = [
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#610
androidx_core: dependencies.create('androidx.core:core-ktx') {
version { strictly '1.5.0-alpha01' }
}
// A newer version gets pulled in with AOSP via core, so we include this here explicitly
implementation('androidx.fragment:fragment-ktx') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#930
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#930
androidx_fragment: dependencies.create('androidx.fragment:fragment-ktx') {
version { strictly '1.3.0-alpha07' }
}
implementation('androidx.preference:preference') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2412
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2412
androidx_preference: dependencies.create('androidx.preference:preference') {
version { strictly '1.1.1' } // should be 1.2.0-alpha01, but that is not even released, yet
}
implementation('androidx.lifecycle:lifecycle-viewmodel-ktx') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1553
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1553
androidx_lifecycle_viewmodel_ktx: dependencies.create('androidx.lifecycle:lifecycle-viewmodel-ktx') {
version { strictly '2.3.0-alpha05' }
}
implementation('androidx.lifecycle:lifecycle-livedata-ktx') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1353
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1353
androidx_lifecycle_livedata_ktx: dependencies.create('androidx.lifecycle:lifecycle-livedata-ktx') {
version { strictly '2.3.0-alpha05' }
}
implementation('androidx.constraintlayout:constraintlayout') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/constraint-layout-x/Android.bp#30
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/constraint-layout-x/Android.bp#30
androidx_constraintlayout: dependencies.create('androidx.constraintlayout:constraintlayout') {
version { strictly '2.0.0-beta7' }
}
implementation('com.google.android.material:material') {
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/material-design-x/Android.bp#6
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#708
androidx_documentfile: dependencies.create('androidx.documentfile:documentfile') {
version { strictly '1.0.1' } // should be 1.1.0-alpha01, but that is not even released, yet
},
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/material-design-x/Android.bp#6
com_google_android_material: dependencies.create('com.google.android.material:material') {
version { strictly '1.1.0-alpha05' }
}
},
]
ext.lint_libs = [
exceptions: 'com.github.thirdegg:lint-rules:0.0.6-beta'
]
/**
* External Dependencies
*
* If the dependencies below are updated,
* please make sure to update the prebuilt libraries and the Android.bp files
* in the top-level `libs` folder to reflect that.
* You can copy these libraries from ~/.gradle/caches/modules-2
*/
def koin_version = '2.1.1' // later versions require newer kotlin version
//noinspection GradleDependency
implementation("org.koin:koin-android:$koin_version") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
}
//noinspection GradleDependency
implementation("org.koin:koin-androidx-viewmodel:$koin_version") {
exclude group: 'org.koin', module: 'koin-androidx-scope'
exclude group: 'androidx.lifecycle'
}
implementation('io.github.novacrypto:BIP39:2019.01.27') {
exclude group: 'com.madgag.spongycastle'
}
/**
* Test Dependencies (do not concern the AOSP build)
*/
lintChecks 'com.github.thirdegg:lint-rules:0.0.5-alpha'
def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
def mockk_version = "1.10.0"
testImplementation aospDeps // anything less than 'implementation' fails tests run with gradlew
testImplementation 'androidx.test.ext:junit:1.1.2'
testImplementation('org.robolectric:robolectric:4.3.1') { // 4.4 has issue with non-idle Looper
// https://github.com/robolectric/robolectric/issues/5245
exclude group: "com.google.auto.service", module: "auto-service"
}
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
testImplementation "io.mockk:mockk:$mockk_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test:rules:1.3.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
}
ext.storage_libs = [
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2711
androidx_room_runtime: dependencies.create('androidx.room:room-runtime') {
version { strictly "$room_version" }
},
// http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
com_google_protobuf_javalite: dependencies.create('com.google.protobuf:protobuf-javalite') {
version { strictly "$protobuf_version" }
},
com_google_crypto_tink_android: dependencies.create('com.google.crypto.tink:tink-android') {
version { strictly '1.5.0' }
},
]

11
gradle/ktlint.gradle Normal file
View file

@ -0,0 +1,11 @@
ktlint {
version = "0.40.0"
android = true
enableExperimentalRules = false
verbose = true
disabledRules = [
"import-ordering",
"no-blank-line-before-rbrace",
"indent", // remove in 0.41 https://github.com/pinterest/ktlint/issues/764
]
}

View file

@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip
distributionSha256Sum=143a28f54f1ae93ef4f72d862dbc3c438050d81bb45b4601eb7076e998362920
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
distributionSha256Sum=22449f5231796abd892c98b2a07c9ceebe4688d192cd2d6763f8e3bf8acbedeb

5
libs/Android.bp Normal file
View file

@ -0,0 +1,5 @@
java_import {
name: "seedvault-lib-kotlin-bip39",
jars: ["kotlin-bip39-1.0.2.jar"],
sdk_version: "current",
}

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