Merge pull request #47 from stevesoltys/develop

Merge develop into master
This commit is contained in:
Steve Soltys 2019-12-22 20:31:55 -05:00 committed by GitHub
commit f8846ffe45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
203 changed files with 10996 additions and 3190 deletions

5
.gitignore vendored
View file

@ -46,4 +46,7 @@ gradle-app.setting
.DS_Store .DS_Store
## Android ## Android
gen/ gen/
## Prebuilt
Backup.apk

View file

@ -1,17 +1,42 @@
dist: trusty
jdk:
- openjdk11
language: android language: android
android: android:
components: components:
- build-tools-28.0.3 - build-tools-29.0.2
- android-28 - android-29
licenses:
- android-sdk-license-.+
- '.+'
before_install:
- mkdir "$ANDROID_HOME/licenses" || true
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" >> "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\nd56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_HOME/licenses/android-sdk-preview-license"
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
script: ./gradlew check assemble
cache:
directories:
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
- $HOME/.android/build-cache
deploy: deploy:
provider: releases provider: script
api_key: script: ./deploy-prebuilt.sh
secure: BJ+0riccbDPQMcvZkYveHHcSa3wlVRdvLIHMJtZQXmjJnYu4Mh2YH4RkZdd+4OBPf2iBCyP1CIxB9NTKldb8Qn1m/6+LcReYf2xd8Y6XCrHDsycT5GZTENEif0EyVPdB1En4NwRVYiNwGMSv49Cz03aGtzq5jrGWxPhYAEY4jt86HKRqw8SCUPEqug3Rz+deG4juUdIAvARiN8jKoqu9EeMOP5ST7nbZjZQbee8SGP7wPW+J7E6kWPvn+mSoZsMXw/ELz8nEAu4pHh/98agreMvApjImpiEpVXNhMpENfk42U+wztiGNspoOh/vDFrNikWFGIJ3lE4yPJteBo2vpVo/7/tfBzKjMnL7c/5ZNMnjv9e2yoqwfpwmh8GzjKaDuwG1Fy8g5ctJAS4wYHr4z4LDlfdmFVUE3r3NPI8XdzsnjVpqkXhC/5eBPO50p82c0Za24SwkmO+JzIaIF41fTt0An9Dd/1Q5321WGJK6HqQwdjRG3HciLF6lNJu/gzSVHnfC9REQGY7vDdNSVaP9ps0W07URewsKwC5Vm5SFYUEFIM2d3C+62+eciqlpfqON6htd9zAZnFTSE6rMTJdGXMs+hLb89C1J3tavz89T2d9Dqnvs6MlKEO3ontDcwYdbx8czPKv22Fm4iI4XG6VTzK9hS4BNCvhvyvqSq7mYIXsA= skip_cleanup: true
file:
- app/build/outputs/apk/release/app-release-unsigned.apk
- app/src/main/permissions_com.stevesoltys.backup.xml
- app/src/main/whitelist_com.stevesoltys.backup.xml
on: on:
repo: stevesoltys/backup repo: stevesoltys/seedvault
tags: true all_branches: true
skip_cleanup: true condition: $TRAVIS_BRANCH =~ ^(master|develop)$

27
Android.mk Normal file
View file

@ -0,0 +1,27 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.seedvault.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := whitelist_com.stevesoltys.seedvault.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/sysconfig
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := Seedvault
LOCAL_SRC_FILES := Seedvault.apk
LOCAL_CERTIFICATE := platform
LOCAL_MODULE_CLASS := APPS
LOCAL_PRIVILEGED_MODULE := true
LOCAL_DEX_PREOPT := false
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.seedvault.xml whitelist_com.stevesoltys.seedvault.xml
include $(BUILD_PREBUILT)

View file

@ -1,3 +1,17 @@
## [1.0.0-alpha1] - 2019-12-14
### Added
- Automatic daily backups that run in the background.
- User friendly UI for creating and restoring backups.
- Support to backing up to and restoring from removable storage.
### Updated
- Application can now be configured in the settings app.
- BIP39 is now used for key generation.
### Notes
- This contains breaking changes, any backups made prior to this release can no longer be restored.
- Application can no longer be built in the Android source tree. It must be built using Gradle and binaries can now be found here: https://github.com/stevesoltys/seedvault-prebuilt
## [0.3.0] - 2019-03-14 ## [0.3.0] - 2019-03-14
### Fixed ### Fixed
- Transport encryption. Some of the application data was not included during encryption. - Transport encryption. Some of the application data was not included during encryption.
@ -11,7 +25,7 @@
## [0.1.2] - 2019-02-11 ## [0.1.2] - 2019-02-11
### Fixed ### Fixed
- Downgrade SDK target version to 26 due to [#15](https://github.com/stevesoltys/backup/issues/15). - Downgrade SDK target version to 26 due to [#15](https://github.com/stevesoltys/seedvault/issues/15).
## [0.1.1] - 2019-02-11 ## [0.1.1] - 2019-02-11
### Added ### Added
@ -20,4 +34,4 @@
- Upgrade target SDK version to 28. - Upgrade target SDK version to 28.
### Fixed ### Fixed
- Ignore `com.android.providers.downloads.ui` to resolve [#14](https://github.com/stevesoltys/backup/issues/14). - Ignore `com.android.providers.downloads.ui` to resolve [#14](https://github.com/stevesoltys/seedvault/issues/14).

215
LICENSE
View file

@ -1,21 +1,202 @@
The MIT License (MIT)
Copyright (c) 2017 Steve Soltys Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in 1. Definitions.
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR "License" shall mean the terms and conditions for use, reproduction,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, and distribution as defined by Sections 1 through 9 of this document.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER "Licensor" shall mean the copyright owner or entity authorized by
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, the copyright owner that is granting the License.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2019] [Steve Soltys]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
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.

View file

@ -1,28 +1,30 @@
# Backup # Seedvault
[![Build Status](https://travis-ci.com/stevesoltys/backup.svg?branch=master)](https://travis-ci.com/stevesoltys/backup) [![Build Status](https://travis-ci.com/stevesoltys/seedvault.svg?branch=master)](https://travis-ci.com/stevesoltys/seedvault)
A backup application for the [Android Open Source Project](https://source.android.com/). A backup application for the [Android Open Source Project](https://source.android.com/).
## Features ## Features
- Backup application data to a zip file. - Backup application data to a flash drive.
- Restore application data from a zip file. - Restore application data from a flash drive.
- Password-based encryption. - User-friendly encryption using a mnemonic phrase (BIP39).
- Automatic daily backups that run in the background.
## Getting Started ## Getting Started
- Check out [the wiki](https://github.com/stevesoltys/backup/wiki) for information on building the application with - Check out [the wiki](https://github.com/stevesoltys/seedvault/wiki) for information on building the application with
AOSP. AOSP.
## What makes this different? ## What makes this different?
This application is compiled with the operating system and does not require a rooted device for use. It uses the same This application is compiled with the operating system and does not require a rooted device for use. It uses the same
internal APIs as `adb backup` and only requires the permission `android.permission.BACKUP` for this. internal APIs as `adb backup` and requires a minimal number of permissions to achieve this.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/backup.
## Permissions ## Permissions
* `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings.
* `android.permission.BACKUP` to be allowed to back up apps ## Contributing
* `android.permission.RECEIVE_BOOT_COMPLETED` to schedule automatic backups after boot Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.
## License ## License
This application is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). This application is available as open source under the terms of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).

View file

@ -1,15 +1,18 @@
import groovy.xml.XmlUtil import groovy.xml.XmlUtil
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android { android {
compileSdkVersion 28 compileSdkVersion 29
buildToolsVersion '28.0.3' buildToolsVersion '29.0.2'
defaultConfig { defaultConfig {
minSdkVersion 26 minSdkVersion 29
targetSdkVersion 28 targetSdkVersion 29
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@ -25,6 +28,23 @@ android {
targetCompatibility 1.8 targetCompatibility 1.8
sourceCompatibility 1.8 sourceCompatibility 1.8
} }
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/sharedTest/java"
}
androidTest {
java.srcDirs += "$projectDir/src/sharedTest/java"
}
}
// optional signingConfigs // optional signingConfigs
def keystorePropertiesFile = rootProject.file("keystore.properties") def keystorePropertiesFile = rootProject.file("keystore.properties")
@ -41,11 +61,15 @@ android {
} }
} }
buildTypes.release.signingConfig = signingConfigs.release buildTypes.release.signingConfig = signingConfigs.release
buildTypes.debug.signingConfig = signingConfigs.release
} }
} }
gradle.projectsEvaluated { gradle.projectsEvaluated {
tasks.withType(JavaCompile) { tasks.withType(JavaCompile) {
if (JavaVersion.current() >= JavaVersion.VERSION_1_9) {
options.compilerArgs.addAll(['--release', '8'])
}
options.compilerArgs.add('-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar') options.compilerArgs.add('-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar')
} }
} }
@ -60,6 +84,7 @@ preBuild.doLast {
parsedXml.component[1].remove(jdkNode) parsedXml.component[1].remove(jdkNode)
def sdkString = "Android API " + android.compileSdkVersion.substring("android-".length()) + " Platform" def sdkString = "Android API " + android.compileSdkVersion.substring("android-".length()) + " Platform"
//noinspection GroovyResultOfObjectAllocationIgnored // the note gets inserted
new Node(parsedXml.component[1], 'orderEntry', ['type': 'jdk', 'jdkName': sdkString, 'jdkType': 'Android SDK']) new Node(parsedXml.component[1], 'orderEntry', ['type': 'jdk', 'jdkName': sdkString, 'jdkType': 'Android SDK'])
XmlUtil.serialize(parsedXml, new FileOutputStream(imlFile)) XmlUtil.serialize(parsedXml, new FileOutputStream(imlFile))
@ -68,15 +93,37 @@ preBuild.doLast {
} }
} }
dependencies { // To produce these binaries, in latest AOSP source tree, run
// To produce these binaries, in latest AOSP source tree, run // $ make
// $ make def aospDeps = fileTree(include: [
compileOnly fileTree(include: [ // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
// out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar 'android.jar',
'android.jar', // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
// out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar 'libcore.jar'
'libcore.jar' ], dir: 'libs')
], dir: 'libs')
implementation group: 'commons-io', name: 'commons-io', version: '2.6' dependencies {
compileOnly aospDeps
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'commons-io:commons-io:2.6'
implementation 'io.github.novacrypto:BIP39:2019.01.27'
implementation 'org.koin:koin-androidx-viewmodel:2.0.1'
implementation 'androidx.core:core-ktx:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
testImplementation aospDeps
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
testImplementation 'io.mockk:mockk:1.9.3'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,33 @@
package com.stevesoltys.seedvault
import android.util.Log
import androidx.test.filters.LargeTest
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
private val TAG = CipherUniqueNonceTest::class.java.simpleName
private const val ITERATIONS = 1_000_000
@LargeTest
@RunWith(AndroidJUnit4::class)
class CipherUniqueNonceTest {
private val keyManager = KeyManagerTestImpl()
private val cipherFactory = CipherFactoryImpl(keyManager)
private val nonceSet = HashSet<ByteArray>()
@Test
fun testUniqueNonce() {
for (i in 1..ITERATIONS) {
val iv = cipherFactory.createEncryptionCipher().iv
Log.w(TAG, "$i: ${iv.toHexString()}")
assertTrue(nonceSet.add(iv))
}
}
}

View file

@ -0,0 +1,71 @@
package com.stevesoltys.seedvault
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.KoinComponent
import org.koin.core.inject
import kotlin.random.Random
private const val filename = "test-file"
@RunWith(AndroidJUnit4::class)
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, settingsManager)
private lateinit var file: DocumentFile
@Before
fun setup() {
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
file = storage.rootBackupDir?.createOrGetFile(filename)
?: throw RuntimeException("Could not create test file")
}
@After
fun tearDown() {
file.delete()
}
@Test
fun testWritingAndReadingFile() {
// write to output stream
val outputStream = storage.getOutputStream(file)
val content = ByteArray(1337).apply { Random.nextBytes(this) }
outputStream.write(content)
outputStream.flush()
outputStream.close()
// read written data from input stream
val inputStream = storage.getInputStream(file)
val readContent = inputStream.readBytes()
inputStream.close()
assertArrayEquals(content, readContent)
// write smaller content to same file
val outputStream2 = storage.getOutputStream(file)
val content2 = ByteArray(42).apply { Random.nextBytes(this) }
outputStream2.write(content2)
outputStream2.flush()
outputStream2.close()
// read written data from input stream
val inputStream2 = storage.getInputStream(file)
val readContent2 = inputStream2.readBytes()
inputStream2.close()
assertArrayEquals(content2, readContent2)
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="show_restore_in_settings">true</bool>
<string-array name="storage_authority_whitelist">
<item>com.android.externalstorage.documents</item>
<item>org.nextcloud.documents</item>
<item>org.nextcloud.beta.documents</item>
</string-array>
</resources>

View file

@ -1,35 +0,0 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := whitelist_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/sysconfig
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE_TAGS := optional
LOCAL_PREBUILT_STATIC_JAVA_LIBRARIES := \
commons-io:../../libs/commons-io-2.6.jar
include $(BUILD_MULTI_PREBUILT)
include $(CLEAR_VARS)
LOCAL_PACKAGE_NAME := Backup
LOCAL_MODULE_TAGS := optional
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.backup.xml whitelist_com.stevesoltys.backup.xml
LOCAL_PRIVILEGED_MODULE := true
LOCAL_PRIVATE_PLATFORM_APIS := true
LOCAL_CERTIFICATE := platform
LOCAL_STATIC_JAVA_LIBRARIES := commons-io
LOCAL_SRC_FILES := $(call all-java-files-under, java)
LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
include $(BUILD_PACKAGE)

View file

@ -1,59 +1,86 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="com.stevesoltys.backup" package="com.stevesoltys.seedvault"
android:versionCode="5" android:versionCode="6"
android:versionName="0.3.0"> android:versionName="1.0.0-alpha1">
<uses-sdk
android:minSdkVersion="26"
android:targetSdkVersion="28"
tools:ignore="GradleOverrides,OldTargetApi" />
<uses-permission <uses-permission
android:name="android.permission.BACKUP" android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- This is needed to retrieve the available storage roots -->
<uses-permission
android:name="android.permission.MANAGE_DOCUMENTS"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to access the serial number of USB mass storage devices -->
<uses-permission
android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to change system backup settings -->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<application <application
android:name=".Backup" android:name=".App"
android:supportsRtl="true" android:allowBackup="false"
android:theme="@style/AppTheme"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:allowBackup="false" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning"> tools:ignore="GoogleAppIndexingWarning">
<activity <activity
android:name="com.stevesoltys.backup.activity.MainActivity" android:name=".settings.SettingsActivity"
android:label="@string/app_name"> android:exported="true" />
<activity
android:name=".ui.storage.StorageActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".ui.storage.PermissionGrantActivity"
android:exported="false"
android:permission="android.permission.MANAGE_DOCUMENTS" />
<activity
android:name=".ui.recoverycode.RecoveryCodeActivity"
android:label="@string/recovery_code_title"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".restore.RestoreActivity"
android:exported="true"
android:label="@string/restore_title"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="com.stevesoltys.backup.activity.backup.CreateBackupActivity"
android:parentActivityName="com.stevesoltys.backup.activity.MainActivity" />
<activity
android:name="com.stevesoltys.backup.activity.restore.RestoreBackupActivity"
android:parentActivityName="com.stevesoltys.backup.activity.MainActivity" />
<service <service
android:name="com.stevesoltys.backup.transport.ConfigurableBackupTransportService" android:name=".transport.ConfigurableBackupTransportService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.backup.TRANSPORT_HOST" /> <action android:name="android.backup.TRANSPORT_HOST" />
</intent-filter> </intent-filter>
</service> </service>
<service <receiver
android:name=".service.backup.BackupJobService" android:name=".UsbIntentReceiver"
android:exported="false" android:exported="true">
android:permission="android.permission.BIND_JOB_SERVICE" /> <intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</receiver>
</application> </application>
</manifest> </manifest>

View file

@ -1,12 +0,0 @@
package com.stevesoltys.backup;
import android.app.Application;
/**
* @author Steve Soltys
*/
public class Backup extends Application {
public static final int JOB_ID_BACKGROUND_BACKUP = 1;
}

View file

@ -1,105 +0,0 @@
package com.stevesoltys.backup.activity;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import com.stevesoltys.backup.R;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static com.stevesoltys.backup.settings.SettingsManager.areBackupsScheduled;
public class MainActivity extends Activity implements View.OnClickListener {
public static final int OPEN_DOCUMENT_TREE_REQUEST_CODE = 1;
public static final int OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE = 2;
public static final int LOAD_DOCUMENT_REQUEST_CODE = 3;
private MainActivityController controller;
private Button automaticBackupsButton;
private Button changeLocationButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
controller = new MainActivityController();
findViewById(R.id.create_backup_button).setOnClickListener(this);
findViewById(R.id.restore_backup_button).setOnClickListener(this);
automaticBackupsButton = findViewById(R.id.automatic_backups_button);
automaticBackupsButton.setOnClickListener(this);
if (areBackupsScheduled(this)) automaticBackupsButton.setVisibility(GONE);
changeLocationButton = findViewById(R.id.change_backup_location_button);
changeLocationButton.setOnClickListener(this);
}
@Override
protected void onStart() {
super.onStart();
if (controller.isChangeBackupLocationButtonVisible(this)) {
changeLocationButton.setVisibility(VISIBLE);
} else {
changeLocationButton.setVisibility(GONE);
}
}
@Override
public void onClick(View view) {
int viewId = view.getId();
switch (viewId) {
case R.id.create_backup_button:
controller.onBackupButtonClicked(this);
break;
case R.id.restore_backup_button:
controller.showLoadDocumentActivity(this);
break;
case R.id.automatic_backups_button:
if (controller.onAutomaticBackupsButtonClicked(this)) {
automaticBackupsButton.setVisibility(GONE);
}
break;
case R.id.change_backup_location_button:
controller.onChangeBackupLocationButtonClicked(this);
break;
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent result) {
if (resultCode != Activity.RESULT_OK) {
Log.e(MainActivity.class.getName(), "Error in activity result: " + requestCode);
return;
}
switch (requestCode) {
case OPEN_DOCUMENT_TREE_REQUEST_CODE:
controller.handleChooseFolderResult(result, this, false);
break;
case OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE:
controller.handleChooseFolderResult(result, this, true);
break;
case LOAD_DOCUMENT_REQUEST_CODE:
controller.handleLoadDocumentResult(result, this);
break;
}
}
}

View file

@ -1,155 +0,0 @@
package com.stevesoltys.backup.activity;
import android.app.Activity;
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Intent;
import android.net.Uri;
import android.widget.Toast;
import com.stevesoltys.backup.activity.backup.CreateBackupActivity;
import com.stevesoltys.backup.activity.restore.RestoreBackupActivity;
import com.stevesoltys.backup.service.backup.BackupJobService;
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService;
import static android.app.job.JobInfo.NETWORK_TYPE_UNMETERED;
import static android.content.Intent.ACTION_OPEN_DOCUMENT;
import static android.content.Intent.ACTION_OPEN_DOCUMENT_TREE;
import static android.content.Intent.CATEGORY_OPENABLE;
import static android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION;
import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION;
import static android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
import static com.stevesoltys.backup.Backup.JOB_ID_BACKGROUND_BACKUP;
import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE;
import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE;
import static com.stevesoltys.backup.settings.SettingsManager.getBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManager.getBackupPassword;
import static com.stevesoltys.backup.settings.SettingsManager.setBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManager.setBackupsScheduled;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.DAYS;
/**
* @author Steve Soltys
* @author Torsten Grote
*/
public class MainActivityController {
public static final String DOCUMENT_MIME_TYPE = "application/octet-stream";
void onBackupButtonClicked(Activity parent) {
Uri folderUri = getBackupFolderUri(parent);
if (folderUri == null) {
showChooseFolderActivity(parent, true);
} else {
// ensure that backup service is started
parent.startService(new Intent(parent, ConfigurableBackupTransportService.class));
showCreateBackupActivity(parent);
}
}
boolean isChangeBackupLocationButtonVisible(Activity parent) {
return getBackupFolderUri(parent) != null;
}
private void showChooseFolderActivity(Activity parent, boolean continueToBackup) {
Intent openTreeIntent = new Intent(ACTION_OPEN_DOCUMENT_TREE);
openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
FLAG_GRANT_READ_URI_PERMISSION | FLAG_GRANT_WRITE_URI_PERMISSION);
try {
Intent documentChooser = Intent.createChooser(openTreeIntent, "Select the backup location");
int requestCode = continueToBackup ? OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE : OPEN_DOCUMENT_TREE_REQUEST_CODE;
parent.startActivityForResult(documentChooser, requestCode);
} catch (ActivityNotFoundException ex) {
Toast.makeText(parent, "Please install a file manager.", Toast.LENGTH_SHORT).show();
}
}
void showLoadDocumentActivity(Activity parent) {
Intent loadDocumentIntent = new Intent(ACTION_OPEN_DOCUMENT);
loadDocumentIntent.addCategory(CATEGORY_OPENABLE);
loadDocumentIntent.setType(DOCUMENT_MIME_TYPE);
try {
Intent documentChooser = Intent.createChooser(loadDocumentIntent, "Select the backup location");
parent.startActivityForResult(documentChooser, MainActivity.LOAD_DOCUMENT_REQUEST_CODE);
} catch (ActivityNotFoundException ex) {
Toast.makeText(parent, "Please install a file manager.", Toast.LENGTH_SHORT).show();
}
}
boolean onAutomaticBackupsButtonClicked(Activity parent) {
if (getBackupFolderUri(parent) == null || getBackupPassword(parent) == null) {
Toast.makeText(parent, "Please make at least one manual backup first.", Toast.LENGTH_SHORT).show();
return false;
}
// schedule backups
final ComponentName serviceName = new ComponentName(parent, BackupJobService.class);
JobInfo job = new JobInfo.Builder(JOB_ID_BACKGROUND_BACKUP, serviceName)
.setRequiredNetworkType(NETWORK_TYPE_UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true) // TODO warn the user instead
.setPeriodic(DAYS.toMillis(1))
.setRequiresCharging(true)
.setPersisted(true)
.build();
JobScheduler scheduler = requireNonNull(parent.getSystemService(JobScheduler.class));
scheduler.schedule(job);
// remember that backups were scheduled
setBackupsScheduled(parent);
// show Toast informing the user
Toast.makeText(parent, "Backups will run automatically now", Toast.LENGTH_SHORT).show();
return true;
}
void onChangeBackupLocationButtonClicked(Activity parent) {
showChooseFolderActivity(parent, false);
}
void handleChooseFolderResult(Intent result, Activity parent, boolean continueToBackup) {
if (result == null || result.getData() == null) {
return;
}
Uri folderUri = result.getData();
// persist permission to access backup folder across reboots
int takeFlags = result.getFlags() &
(FLAG_GRANT_READ_URI_PERMISSION | FLAG_GRANT_WRITE_URI_PERMISSION);
parent.getContentResolver().takePersistableUriPermission(folderUri, takeFlags);
// store backup folder location in settings
setBackupFolderUri(parent, folderUri);
if (!continueToBackup) return;
showCreateBackupActivity(parent);
}
private void showCreateBackupActivity(Activity parent) {
Intent intent = new Intent(parent, CreateBackupActivity.class);
parent.startActivity(intent);
}
void handleLoadDocumentResult(Intent result, Activity parent) {
if (result == null) {
return;
}
Intent intent = new Intent(parent, RestoreBackupActivity.class);
intent.setData(result.getData());
parent.startActivity(intent);
}
}

View file

@ -1,60 +0,0 @@
package com.stevesoltys.backup.activity;
import android.app.Activity;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import com.stevesoltys.backup.R;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.IntStream;
/**
* @author Steve Soltys
*/
public abstract class PackageListActivity extends Activity implements AdapterView.OnItemClickListener {
protected ListView packageListView;
protected final Set<String> selectedPackageList = new HashSet<>();
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
String clickedPackage = (String) packageListView.getItemAtPosition(position);
if (!selectedPackageList.remove(clickedPackage)) {
selectedPackageList.add(clickedPackage);
packageListView.setItemChecked(position, true);
} else {
packageListView.setItemChecked(position, false);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_unselect_all) {
IntStream.range(0, packageListView.getCount())
.forEach(position -> {
selectedPackageList.remove((String) packageListView.getItemAtPosition(position));
packageListView.setItemChecked(position, false);
});
return true;
}
return super.onOptionsItemSelected(item);
}
public void preSelectAllPackages() {
IntStream.range(0, packageListView.getCount())
.forEach(position -> {
selectedPackageList.add((String) packageListView.getItemAtPosition(position));
packageListView.setItemChecked(position, true);
});
}
}

View file

@ -1,32 +0,0 @@
package com.stevesoltys.backup.activity;
import android.app.Activity;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupWindow;
import com.stevesoltys.backup.R;
/**
* @author Steve Soltys
*/
public class PopupWindowUtil {
public static PopupWindow showLoadingPopupWindow(Activity parent) {
LayoutInflater inflater = (LayoutInflater) parent.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
ViewGroup popupViewGroup = parent.findViewById(R.id.popup_layout);
View popupView = inflater.inflate(R.layout.progress_popup_window, popupViewGroup);
PopupWindow popupWindow = new PopupWindow(popupView, 750, 350, true);
popupWindow.setBackgroundDrawable(new ColorDrawable(Color.WHITE));
popupWindow.setElevation(10);
popupWindow.setFocusable(false);
popupWindow.showAtLocation(popupView, Gravity.CENTER, 0, 0);
popupWindow.setOutsideTouchable(false);
return popupWindow;
}
}

View file

@ -1,37 +0,0 @@
package com.stevesoltys.backup.activity.backup;
import android.os.RemoteException;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.session.backup.BackupResult;
import com.stevesoltys.backup.session.backup.BackupSession;
/**
* @author Steve Soltys
*/
public class BackupPopupWindowListener implements Button.OnClickListener {
private static final String TAG = BackupPopupWindowListener.class.getName();
private final BackupSession backupSession;
public BackupPopupWindowListener(BackupSession backupSession) {
this.backupSession = backupSession;
}
@Override
public void onClick(View view) {
int viewId = view.getId();
if (viewId == R.id.popup_cancel_button) {
try {
backupSession.stop(BackupResult.CANCELLED);
} catch (RemoteException e) {
Log.e(TAG, "Error cancelling backup session: ", e);
}
}
}
}

View file

@ -1,45 +0,0 @@
package com.stevesoltys.backup.activity.backup;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PackageListActivity;
public class CreateBackupActivity extends PackageListActivity implements View.OnClickListener {
private CreateBackupActivityController controller;
@Override
public void onClick(View view) {
int viewId = view.getId();
if (viewId == R.id.create_confirm_button) {
controller.onCreateBackupButtonClicked(selectedPackageList, this);
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_create_backup);
findViewById(R.id.create_confirm_button).setOnClickListener(this);
packageListView = findViewById(R.id.create_package_list);
selectedPackageList.clear();
controller = new CreateBackupActivityController();
AsyncTask.execute(() -> controller.populatePackageList(packageListView, CreateBackupActivity.this));
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.backup_menu, menu);
return true;
}
}

View file

@ -1,133 +0,0 @@
package com.stevesoltys.backup.activity.backup;
import android.app.Activity;
import android.app.AlertDialog;
import android.os.RemoteException;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupWindow;
import android.widget.TextView;
import android.widget.Toast;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PopupWindowUtil;
import com.stevesoltys.backup.service.PackageService;
import com.stevesoltys.backup.service.backup.BackupService;
import com.stevesoltys.backup.settings.SettingsManager;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
/**
* @author Steve Soltys
*/
class CreateBackupActivityController {
private static final String TAG = CreateBackupActivityController.class.getName();
private final BackupService backupService = new BackupService();
private final PackageService packageService = new PackageService();
void populatePackageList(ListView packageListView, CreateBackupActivity parent) {
AtomicReference<PopupWindow> popupWindow = new AtomicReference<>();
parent.runOnUiThread(() -> {
popupWindow.set(PopupWindowUtil.showLoadingPopupWindow(parent));
TextView textView = popupWindow.get().getContentView().findViewById(R.id.popup_text_view);
textView.setText(R.string.loading_packages);
View popupWindowButton = popupWindow.get().getContentView().findViewById(R.id.popup_cancel_button);
popupWindowButton.setOnClickListener(view -> parent.finish());
});
String[] eligiblePackageList;
try {
eligiblePackageList = packageService.getEligiblePackages();
} catch (RemoteException e) {
Log.e(TAG, "Error while obtaining package list: ", e);
Toast.makeText(parent, "Error obtaining package list", Toast.LENGTH_SHORT).show();
parent.finish();
return;
}
parent.runOnUiThread(() -> {
if (popupWindow.get() != null) {
popupWindow.get().dismiss();
}
packageListView.setOnItemClickListener(parent);
packageListView.setAdapter(new ArrayAdapter<>(parent, R.layout.checked_list_item, eligiblePackageList));
packageListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
parent.preSelectAllPackages();
});
}
void onCreateBackupButtonClicked(Set<String> selectedPackages, Activity parent) {
String password = SettingsManager.getBackupPassword(parent);
if (password == null) {
showEnterPasswordAlert(selectedPackages, parent);
} else {
backupService.backupPackageData(selectedPackages, parent);
}
}
private void showEnterPasswordAlert(Set<String> selectedPackages, Activity parent) {
final EditText passwordTextView = new EditText(parent);
passwordTextView.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
new AlertDialog.Builder(parent)
.setTitle("Enter a password")
.setMessage("You'll need this to restore your backup, so write it down!")
.setView(passwordTextView)
.setPositiveButton("Set password", (dialog, button) -> {
if (passwordTextView.getText().length() == 0) {
Toast.makeText(parent, "Please enter a password", Toast.LENGTH_SHORT).show();
dialog.cancel();
showEnterPasswordAlert(selectedPackages, parent);
} else {
showConfirmPasswordAlert(selectedPackages, parent,
passwordTextView.getText().toString());
}
})
.setNegativeButton("Cancel", (dialog, button) -> dialog.cancel())
.show();
}
private void showConfirmPasswordAlert(Set<String> selectedPackages, Activity parent,
String originalPassword) {
final EditText passwordTextView = new EditText(parent);
passwordTextView.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
new AlertDialog.Builder(parent)
.setTitle("Confirm password")
.setView(passwordTextView)
.setPositiveButton("Confirm", (dialog, button) -> {
String password = passwordTextView.getText().toString();
if (originalPassword.equals(password)) {
SettingsManager.setBackupPassword(parent, password);
backupService.backupPackageData(selectedPackages, parent);
} else {
new AlertDialog.Builder(parent)
.setMessage("Passwords do not match, please try again.")
.setPositiveButton("Ok", (dialog2, button2) -> dialog2.dismiss())
.show();
dialog.cancel();
}
})
.setNegativeButton("Cancel", (dialog, button) -> dialog.cancel())
.show();
}
}

View file

@ -1,50 +0,0 @@
package com.stevesoltys.backup.activity.restore;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PackageListActivity;
public class RestoreBackupActivity extends PackageListActivity implements View.OnClickListener {
private RestoreBackupActivityController controller;
private Uri contentUri;
@Override
public void onClick(View view) {
int viewId = view.getId();
if (viewId == R.id.restore_confirm_button) {
controller.showEnterPasswordAlert(selectedPackageList, contentUri, this);
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_restore_backup);
findViewById(R.id.restore_confirm_button).setOnClickListener(this);
packageListView = findViewById(R.id.restore_package_list);
selectedPackageList.clear();
contentUri = getIntent().getData();
controller = new RestoreBackupActivityController();
AsyncTask.execute(() -> controller.populatePackageList(packageListView, contentUri, this));
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.backup_menu, menu);
return true;
}
}

View file

@ -1,116 +0,0 @@
package com.stevesoltys.backup.activity.restore;
import android.app.Activity;
import android.app.AlertDialog;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupWindow;
import android.widget.TextView;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PopupWindowUtil;
import com.stevesoltys.backup.service.restore.RestoreService;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import libcore.io.IoUtils;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
/**
* @author Steve Soltys
*/
class RestoreBackupActivityController {
private static final String TAG = RestoreBackupActivityController.class.getName();
private final RestoreService restoreService = new RestoreService();
void populatePackageList(ListView packageListView, Uri contentUri, RestoreBackupActivity parent) {
AtomicReference<PopupWindow> popupWindow = new AtomicReference<>();
parent.runOnUiThread(() -> {
popupWindow.set(PopupWindowUtil.showLoadingPopupWindow(parent));
TextView textView = popupWindow.get().getContentView().findViewById(R.id.popup_text_view);
textView.setText(R.string.loading_backup);
View popupWindowButton = popupWindow.get().getContentView().findViewById(R.id.popup_cancel_button);
popupWindowButton.setOnClickListener(view -> parent.finish());
});
List<String> eligiblePackageList = new LinkedList<>();
try {
eligiblePackageList.addAll(getEligiblePackages(contentUri, parent));
} catch (IOException e) {
Log.e(TAG, "Error while obtaining package list: ", e);
}
parent.runOnUiThread(() -> {
if (popupWindow.get() != null) {
popupWindow.get().dismiss();
}
packageListView.setOnItemClickListener(parent);
packageListView.setAdapter(new ArrayAdapter<>(parent, R.layout.checked_list_item, eligiblePackageList));
packageListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
parent.preSelectAllPackages();
});
}
private List<String> getEligiblePackages(Uri contentUri, Activity context) throws IOException {
List<String> results = new LinkedList<>();
ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(contentUri, "r");
FileInputStream fileInputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
ZipInputStream inputStream = new ZipInputStream(fileInputStream);
ZipEntry zipEntry;
while ((zipEntry = inputStream.getNextEntry()) != null) {
String zipEntryPath = zipEntry.getName();
if (zipEntryPath.startsWith(DEFAULT_FULL_BACKUP_DIRECTORY)) {
String fileName = new File(zipEntryPath).getName();
results.add(fileName);
}
inputStream.closeEntry();
}
IoUtils.closeQuietly(inputStream);
IoUtils.closeQuietly(fileDescriptor.getFileDescriptor());
return results;
}
void showEnterPasswordAlert(Set<String> selectedPackages, Uri contentUri, Activity parent) {
final EditText passwordTextView = new EditText(parent);
passwordTextView.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
new AlertDialog.Builder(parent)
.setTitle("Enter a password")
.setMessage("If you didn't enter one while creating the backup, you can leave this blank.")
.setView(passwordTextView)
.setPositiveButton("Confirm", (dialog, button) ->
restoreService.restorePackages(selectedPackages, contentUri, parent,
passwordTextView.getText().toString()))
.setNegativeButton("Cancel", (dialog, button) -> dialog.cancel())
.show();
}
}

View file

@ -1,41 +0,0 @@
package com.stevesoltys.backup.activity.restore;
import android.os.RemoteException;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.session.restore.RestoreResult;
import com.stevesoltys.backup.session.restore.RestoreSession;
/**
* @author Steve Soltys
*/
public class RestorePopupWindowListener implements Button.OnClickListener {
private static final String TAG = RestorePopupWindowListener.class.getName();
private final RestoreSession restoreSession;
public RestorePopupWindowListener(RestoreSession restoreSession) {
this.restoreSession = restoreSession;
}
@Override
public void onClick(View view) {
int viewId = view.getId();
switch (viewId) {
case R.id.popup_cancel_button:
try {
restoreSession.stop(RestoreResult.CANCELLED);
} catch (RemoteException e) {
Log.e(TAG, "Error cancelling restore session: ", e);
}
break;
}
}
}

View file

@ -1,79 +0,0 @@
package com.stevesoltys.backup.security;
import javax.crypto.*;
import javax.crypto.spec.IvParameterSpec;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
/**
* A utility class for encrypting and decrypting data using a {@link Cipher}.
*
* @author Steve Soltys
*/
public class CipherUtil {
/**
* The cipher algorithm.
*/
public static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
/**
* .
* Encrypts the given payload using the provided secret key.
*
* @param payload The payload.
* @param secretKey The secret key.
* @param iv The initialization vector.
*/
public static byte[] encrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
InvalidAlgorithmParameterException, InvalidKeyException {
return startEncrypt(secretKey, iv).doFinal(payload);
}
/**
* Initializes a cipher in {@link Cipher#ENCRYPT_MODE}.
*
* @param secretKey The secret key.
* @param iv The initialization vector.
* @return The initialized cipher.
*/
public static Cipher startEncrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher;
}
/**
* Decrypts the given payload using the provided secret key.
*
* @param payload The payload.
* @param secretKey The secret key.
* @param iv The initialization vector.
*/
public static byte[] decrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
InvalidAlgorithmParameterException, InvalidKeyException {
return startDecrypt(secretKey, iv).doFinal(payload);
}
/**
* Initializes a cipher in {@link Cipher#DECRYPT_MODE}.
*
* @param secretKey The secret key.
* @param iv The initialization vector.
* @return The initialized cipher.
*/
public static Cipher startDecrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher;
}
}

View file

@ -1,44 +0,0 @@
package com.stevesoltys.backup.security;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
/**
* A utility class which can be used for generating an AES secret key using PBKDF2.
*
* @author Steve Soltys
*/
public class KeyGenerator {
/**
* The number of iterations for key generation.
*/
private static final int ITERATIONS = 32767;
/**
* The generated key length.
*/
private static final int KEY_LENGTH = 256;
/**
* Generates an AES secret key using PBKDF2.
*
* @param password The password.
* @param salt The salt.
* @return The generated key.
*/
public static SecretKey generate(String password, byte[] salt)
throws NoSuchAlgorithmException, InvalidKeySpecException {
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
SecretKey secretKey = secretKeyFactory.generateSecret(keySpec);
return new SecretKeySpec(secretKey.getEncoded(), "AES");
}
}

View file

@ -1,48 +0,0 @@
package com.stevesoltys.backup.service;
import android.app.backup.IBackupManager;
import android.content.pm.IPackageManager;
import android.content.pm.PackageInfo;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.UserHandle;
import java.util.List;
import java.util.Set;
import static com.google.android.collect.Sets.newArraySet;
/**
* @author Steve Soltys
*/
public class PackageService {
private final IBackupManager backupManager;
private final IPackageManager packageManager;
private static final Set<String> IGNORED_PACKAGES = newArraySet(
"com.android.externalstorage",
"com.android.providers.downloads.ui",
"com.android.providers.downloads",
"com.android.providers.media",
"com.android.providers.calendar",
"com.android.providers.contacts",
"com.stevesoltys.backup"
);
public PackageService() {
backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup"));
packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));
}
public String[] getEligiblePackages() throws RemoteException {
List<PackageInfo> packages = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).getList();
String[] packageArray = packages.stream()
.map(packageInfo -> packageInfo.packageName)
.filter(packageName -> !IGNORED_PACKAGES.contains(packageName))
.toArray(String[]::new);
return backupManager.filterAppsEligibleForBackup(packageArray);
}
}

View file

@ -1,56 +0,0 @@
package com.stevesoltys.backup.service;
import android.app.backup.IBackupManager;
import android.os.RemoteException;
import android.os.ServiceManager;
import com.stevesoltys.backup.session.backup.BackupSession;
import com.stevesoltys.backup.session.backup.BackupSessionObserver;
import com.stevesoltys.backup.session.restore.RestoreSession;
import com.stevesoltys.backup.session.restore.RestoreSessionObserver;
import java.util.Set;
/**
* @author Steve Soltys
*/
public class TransportService {
private static final String BACKUP_TRANSPORT = "com.stevesoltys.backup.transport.ConfigurableBackupTransport";
private final IBackupManager backupManager;
public TransportService() {
backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup"));
}
public BackupSession backup(BackupSessionObserver observer, Set<String> packages) throws RemoteException {
if (!BACKUP_TRANSPORT.equals(backupManager.getCurrentTransport())) {
backupManager.selectBackupTransport(BACKUP_TRANSPORT);
}
if (!backupManager.isBackupEnabled()) {
backupManager.setBackupEnabled(true);
}
BackupSession backupSession = new BackupSession(backupManager, observer, packages);
backupSession.start();
return backupSession;
}
public RestoreSession restore(RestoreSessionObserver observer, Set<String> packages) throws RemoteException {
if (!BACKUP_TRANSPORT.equals(backupManager.getCurrentTransport())) {
backupManager.selectBackupTransport(BACKUP_TRANSPORT);
}
if (!backupManager.isBackupEnabled()) {
backupManager.setBackupEnabled(true);
}
RestoreSession restoreSession = new RestoreSession(backupManager, observer, packages);
restoreSession.start();
return restoreSession;
}
}

View file

@ -1,61 +0,0 @@
package com.stevesoltys.backup.service.backup;
import android.app.backup.BackupManager;
import android.app.backup.IBackupManager;
import android.app.job.JobParameters;
import android.app.job.JobService;
import android.content.Intent;
import android.os.RemoteException;
import android.util.Log;
import com.stevesoltys.backup.service.PackageService;
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService;
import static android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP;
import static android.os.ServiceManager.getService;
public class BackupJobService extends JobService {
private final static String TAG = BackupJobService.class.getName();
private final IBackupManager backupManager;
private final PackageService packageService = new PackageService();
public BackupJobService() {
backupManager = IBackupManager.Stub.asInterface(getService("backup"));
}
@Override
public boolean onStartJob(JobParameters params) {
Log.i(TAG, "Triggering full backup");
startService(new Intent(this, ConfigurableBackupTransportService.class));
try {
String[] packages = packageService.getEligiblePackages();
// TODO use an observer to know when backups fail
int result = backupManager.requestBackup(packages, null, null, FLAG_NON_INCREMENTAL_BACKUP);
if (result == BackupManager.SUCCESS) {
Log.i(TAG, "Backup succeeded ");
} else {
Log.e(TAG, "Backup failed: " + result);
}
// TODO show notification on backup error
} catch (RemoteException e) {
Log.e(TAG, "Error during backup: ", e);
} finally {
jobFinished(params, false);
}
return true;
}
@Override
public boolean onStopJob(JobParameters params) {
try {
backupManager.cancelBackups();
} catch (RemoteException e) {
Log.e(TAG, "Error cancelling backup: ", e);
}
return true;
}
}

View file

@ -1,82 +0,0 @@
package com.stevesoltys.backup.service.backup;
import android.app.Activity;
import android.app.backup.BackupProgress;
import android.widget.PopupWindow;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.session.backup.BackupResult;
import com.stevesoltys.backup.session.backup.BackupSession;
import com.stevesoltys.backup.session.backup.BackupSessionObserver;
import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
/**
* @author Steve Soltys
*/
class BackupObserver implements BackupSessionObserver {
private final Activity context;
private final PopupWindow popupWindow;
BackupObserver(Activity context, PopupWindow popupWindow) {
this.context = context;
this.popupWindow = popupWindow;
}
@Override
public void backupPackageStarted(BackupSession backupSession, String packageName, BackupProgress backupProgress) {
context.runOnUiThread(() -> {
TextView textView = popupWindow.getContentView().findViewById(R.id.popup_text_view);
if (textView != null) {
textView.setText(packageName);
}
ProgressBar progressBar = popupWindow.getContentView().findViewById(R.id.popup_progress_bar);
if (progressBar != null) {
progressBar.setMax((int) backupProgress.bytesExpected);
progressBar.setProgress((int) backupProgress.bytesTransferred);
}
});
}
@Override
public void backupPackageCompleted(BackupSession backupSession, String packageName, BackupResult result) {
context.runOnUiThread(() -> {
TextView textView = popupWindow.getContentView().findViewById(R.id.popup_text_view);
if (textView != null) {
textView.setText(packageName);
}
});
}
@Override
public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) {
if (backupResult == BackupResult.SUCCESS) getBackupTransport(context).backupFinished();
context.runOnUiThread(() -> {
if (backupResult == BackupResult.SUCCESS) {
Toast.makeText(context, R.string.backup_success, Toast.LENGTH_LONG).show();
} else if (backupResult == BackupResult.CANCELLED) {
Toast.makeText(context, R.string.backup_cancelled, Toast.LENGTH_LONG).show();
} else {
Toast.makeText(context, R.string.backup_failure, Toast.LENGTH_LONG).show();
}
popupWindow.dismiss();
context.finish();
});
}
}

View file

@ -1,46 +0,0 @@
package com.stevesoltys.backup.service.backup;
import android.app.Activity;
import android.util.Log;
import android.view.View;
import android.widget.PopupWindow;
import android.widget.TextView;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PopupWindowUtil;
import com.stevesoltys.backup.activity.backup.BackupPopupWindowListener;
import com.stevesoltys.backup.service.TransportService;
import com.stevesoltys.backup.session.backup.BackupSession;
import java.util.Set;
/**
* @author Steve Soltys
*/
public class BackupService {
private static final String TAG = BackupService.class.getName();
private final TransportService transportService = new TransportService();
public void backupPackageData(Set<String> selectedPackages, Activity parent) {
try {
selectedPackages.add("@pm@");
Log.i(TAG, "Backing up " + selectedPackages.size() + " packages...");
PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent);
BackupObserver backupObserver = new BackupObserver(parent, popupWindow);
BackupSession backupSession = transportService.backup(backupObserver, selectedPackages);
View popupWindowButton = popupWindow.getContentView().findViewById(R.id.popup_cancel_button);
popupWindowButton.setOnClickListener(new BackupPopupWindowListener(backupSession));
TextView textView = popupWindow.getContentView().findViewById(R.id.popup_text_view);
textView.setText(R.string.initializing);
} catch (Exception e) {
Log.e(TAG, "Error while running backup: ", e);
}
}
}

View file

@ -1,73 +0,0 @@
package com.stevesoltys.backup.service.restore;
import android.app.Activity;
import android.widget.PopupWindow;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.session.restore.RestoreResult;
import com.stevesoltys.backup.session.restore.RestoreSessionObserver;
/**
* @author Steve Soltys
*/
class RestoreObserver implements RestoreSessionObserver {
private final Activity context;
private final PopupWindow popupWindow;
private final int packageCount;
RestoreObserver(Activity context, PopupWindow popupWindow, int packageCount) {
this.context = context;
this.popupWindow = popupWindow;
this.packageCount = packageCount;
}
@Override
public void restoreSessionStarted(int packageCount) {
context.runOnUiThread(() -> {
TextView textView = popupWindow.getContentView().findViewById(R.id.popup_text_view);
textView.setText(R.string.initializing);
});
}
@Override
public void restorePackageStarted(int packageIndex, String packageName) {
context.runOnUiThread(() -> {
ProgressBar progressBar = popupWindow.getContentView().findViewById(R.id.popup_progress_bar);
if (progressBar != null) {
progressBar.setMax(packageCount);
progressBar.setProgress(packageIndex);
}
TextView textView = popupWindow.getContentView().findViewById(R.id.popup_text_view);
if (textView != null) {
textView.setText(packageName);
}
});
}
@Override
public void restoreSessionCompleted(RestoreResult restoreResult) {
context.runOnUiThread(() -> {
if (restoreResult == RestoreResult.SUCCESS) {
Toast.makeText(context, R.string.restore_success, Toast.LENGTH_LONG).show();
} else if (restoreResult == RestoreResult.CANCELLED) {
Toast.makeText(context, R.string.restore_cancelled, Toast.LENGTH_LONG).show();
} else {
Toast.makeText(context, R.string.restore_failure, Toast.LENGTH_LONG).show();
}
popupWindow.dismiss();
context.finish();
});
}
}

View file

@ -1,48 +0,0 @@
package com.stevesoltys.backup.service.restore;
import android.app.Activity;
import android.net.Uri;
import android.os.RemoteException;
import android.util.Log;
import android.view.View;
import android.widget.PopupWindow;
import com.stevesoltys.backup.R;
import com.stevesoltys.backup.activity.PopupWindowUtil;
import com.stevesoltys.backup.activity.restore.RestorePopupWindowListener;
import com.stevesoltys.backup.service.TransportService;
import com.stevesoltys.backup.session.restore.RestoreSession;
import com.stevesoltys.backup.transport.ConfigurableBackupTransport;
import java.util.Set;
import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
/**
* @author Steve Soltys
*/
public class RestoreService {
private static final String TAG = RestoreService.class.getName();
private final TransportService transportService = new TransportService();
public void restorePackages(Set<String> selectedPackages, Uri contentUri, Activity parent, String password) {
ConfigurableBackupTransport backupTransport = getBackupTransport(parent.getApplication());
backupTransport.prepareRestore(password, contentUri);
try {
PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent);
RestoreObserver restoreObserver = new RestoreObserver(parent, popupWindow, selectedPackages.size());
RestoreSession restoreSession = transportService.restore(restoreObserver, selectedPackages);
View popupWindowButton = popupWindow.getContentView().findViewById(R.id.popup_cancel_button);
if (popupWindowButton != null) {
popupWindowButton.setOnClickListener(new RestorePopupWindowListener(restoreSession));
}
} catch (RemoteException e) {
Log.e(TAG, "Error while running restore: ", e);
}
}
}

View file

@ -1,20 +0,0 @@
package com.stevesoltys.backup.session.backup;
import android.app.backup.IBackupManagerMonitor;
import android.os.Bundle;
import android.util.Log;
import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_CATEGORY;
import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_ID;
import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_PACKAGE_NAME;
class BackupMonitor extends IBackupManagerMonitor.Stub {
@Override
public void onEvent(Bundle bundle) {
Log.d("BackupMonitor", "ID: " + bundle.getInt(EXTRA_LOG_EVENT_ID));
Log.d("BackupMonitor", "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1));
Log.d("BackupMonitor", "PACKAGE: " + bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?"));
}
}

View file

@ -1,8 +0,0 @@
package com.stevesoltys.backup.session.backup;
/**
* @author Steve Soltys
*/
public enum BackupResult {
SUCCESS, FAILURE, CANCELLED
}

View file

@ -1,67 +0,0 @@
package com.stevesoltys.backup.session.backup;
import android.app.backup.BackupManager;
import android.app.backup.BackupProgress;
import android.app.backup.IBackupManager;
import android.app.backup.IBackupObserver;
import android.os.RemoteException;
import java.util.Set;
import static android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP;
/**
* @author Steve Soltys
*/
public class BackupSession extends IBackupObserver.Stub {
private final IBackupManager backupManager;
private final BackupSessionObserver backupSessionObserver;
private final Set<String> packages;
public BackupSession(IBackupManager backupManager, BackupSessionObserver backupSessionObserver,
Set<String> packages) {
this.backupManager = backupManager;
this.backupSessionObserver = backupSessionObserver;
this.packages = packages;
}
public void start() throws RemoteException {
String [] selectedPackageArray = packages.toArray(new String[0]);
backupManager.requestBackup(selectedPackageArray, this, new BackupMonitor(), FLAG_NON_INCREMENTAL_BACKUP);
}
public void stop(BackupResult result) throws RemoteException {
backupManager.cancelBackups();
backupSessionObserver.backupSessionCompleted(this, result);
}
@Override
public void onUpdate(String currentPackage, BackupProgress backupProgress) {
backupSessionObserver.backupPackageStarted(this, currentPackage, backupProgress);
}
@Override
public void onResult(String currentPackage, int status) {
backupSessionObserver.backupPackageCompleted(this, currentPackage, getBackupResult(status));
}
@Override
public void backupFinished(int status) {
backupSessionObserver.backupSessionCompleted(this, getBackupResult(status));
}
private BackupResult getBackupResult(int status) {
if (status == BackupManager.SUCCESS) {
return BackupResult.SUCCESS;
} else if (status == BackupManager.ERROR_BACKUP_CANCELLED) {
return BackupResult.CANCELLED;
} else {
return BackupResult.FAILURE;
}
}
}

View file

@ -1,15 +0,0 @@
package com.stevesoltys.backup.session.backup;
import android.app.backup.BackupProgress;
/**
* @author Steve Soltys
*/
public interface BackupSessionObserver {
void backupPackageStarted(BackupSession backupSession, String packageName, BackupProgress backupProgress);
void backupPackageCompleted(BackupSession backupSession, String packageName, BackupResult result);
void backupSessionCompleted(BackupSession backupSession, BackupResult result);
}

View file

@ -1,8 +0,0 @@
package com.stevesoltys.backup.session.restore;
/**
* @author Steve Soltys
*/
public enum RestoreResult {
SUCCESS, CANCELLED, FAILURE
}

View file

@ -1,95 +0,0 @@
package com.stevesoltys.backup.session.restore;
import android.app.backup.*;
import android.os.RemoteException;
import java.util.Set;
/**
* @author Steve Soltys
*/
public class RestoreSession extends IRestoreObserver.Stub {
private final IBackupManager backupManager;
private final RestoreSessionObserver observer;
private final Set<String> packages;
private IRestoreSession restoreSession;
public RestoreSession(IBackupManager backupManager, RestoreSessionObserver observer, Set<String> packages) {
this.backupManager = backupManager;
this.observer = observer;
this.packages = packages;
}
public void start() throws RemoteException {
if (restoreSession != null || packages.isEmpty()) {
observer.restoreSessionCompleted(RestoreResult.FAILURE);
return;
}
restoreSession = backupManager.beginRestoreSession(null, null);
if (restoreSession == null) {
stop(RestoreResult.FAILURE);
return;
}
int result = restoreSession.getAvailableRestoreSets(this, null);
if (result != BackupManager.SUCCESS) {
stop(RestoreResult.FAILURE);
}
}
public void stop(RestoreResult restoreResult) throws RemoteException {
clearSession();
observer.restoreSessionCompleted(restoreResult);
}
private void clearSession() throws RemoteException {
if (restoreSession != null) {
restoreSession.endRestoreSession();
restoreSession = null;
}
}
@Override
public void restoreSetsAvailable(RestoreSet[] restoreSets) throws RemoteException {
if (restoreSets.length > 0) {
RestoreSet restoreSet = restoreSets[0];
String[] packageArray = packages.toArray(new String[0]);
int result = restoreSession.restoreSome(restoreSet.token, this, null, packageArray);
if (result != BackupManager.SUCCESS) {
stop(RestoreResult.FAILURE);
}
}
}
@Override
public void restoreStarting(int numPackages) {
observer.restoreSessionStarted(numPackages);
}
@Override
public void onUpdate(int nowBeingRestored, String currentPackage) {
observer.restorePackageStarted(nowBeingRestored, currentPackage);
}
@Override
public void restoreFinished(int result) throws RemoteException {
if (result == BackupManager.SUCCESS) {
stop(RestoreResult.SUCCESS);
} else if (result == BackupManager.ERROR_BACKUP_CANCELLED) {
stop(RestoreResult.CANCELLED);
} else {
stop(RestoreResult.FAILURE);
}
}
}

View file

@ -1,13 +0,0 @@
package com.stevesoltys.backup.session.restore;
/**
* @author Steve Soltys
*/
public interface RestoreSessionObserver {
void restoreSessionStarted(int packageCount);
void restorePackageStarted(int packageIndex, String packageName);
void restoreSessionCompleted(RestoreResult restoreResult);
}

View file

@ -1,57 +0,0 @@
package com.stevesoltys.backup.settings;
import android.annotation.Nullable;
import android.content.Context;
import android.net.Uri;
import static android.preference.PreferenceManager.getDefaultSharedPreferences;
public class SettingsManager {
private static final String PREF_KEY_BACKUP_URI = "backupUri";
private static final String PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword";
private static final String PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled";
public static void setBackupFolderUri(Context context, Uri uri) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_BACKUP_URI, uri.toString())
.apply();
}
@Nullable
public static Uri getBackupFolderUri(Context context) {
String uriStr = getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_URI, null);
if (uriStr == null) return null;
return Uri.parse(uriStr);
}
/**
* This is insecure and not supposed to be part of a release,
* but rather an intermediate step towards a generated passphrase.
*/
public static void setBackupPassword(Context context, String password) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_BACKUP_PASSWORD, password)
.apply();
}
@Nullable
public static String getBackupPassword(Context context) {
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null);
}
public static void setBackupsScheduled(Context context) {
getDefaultSharedPreferences(context)
.edit()
.putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true)
.apply();
}
@Nullable
public static Boolean areBackupsScheduled(Context context) {
return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false);
}
}

View file

@ -1,189 +0,0 @@
package com.stevesoltys.backup.transport;
import android.app.backup.BackupTransport;
import android.app.backup.RestoreDescription;
import android.app.backup.RestoreSet;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import com.stevesoltys.backup.transport.component.BackupComponent;
import com.stevesoltys.backup.transport.component.RestoreComponent;
import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupComponent;
import com.stevesoltys.backup.transport.component.provider.ContentProviderRestoreComponent;
import static android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED;
import static android.os.Build.VERSION.SDK_INT;
/**
* @author Steve Soltys
*/
public class ConfigurableBackupTransport extends BackupTransport {
private static final String TRANSPORT_DIRECTORY_NAME =
"com.stevesoltys.backup.transport.ConfigurableBackupTransport";
private static final String TAG = TRANSPORT_DIRECTORY_NAME;
private final BackupComponent backupComponent;
private final RestoreComponent restoreComponent;
ConfigurableBackupTransport(Context context) {
backupComponent = new ContentProviderBackupComponent(context);
restoreComponent = new ContentProviderRestoreComponent(context);
}
public void prepareRestore(String password, Uri fileUri) {
restoreComponent.prepareRestore(password, fileUri);
}
@Override
public String transportDirName() {
return TRANSPORT_DIRECTORY_NAME;
}
@Override
public String name() {
// TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
return this.getClass().getName();
}
@Override
public int getTransportFlags() {
if (SDK_INT >= 28) return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED;
return 0;
}
@Override
public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) {
return true;
}
@Override
public long requestBackupTime() {
return backupComponent.requestBackupTime();
}
@Override
public String dataManagementLabel() {
return backupComponent.dataManagementLabel();
}
@Override
public int initializeDevice() {
return backupComponent.initializeDevice();
}
@Override
public String currentDestinationString() {
return backupComponent.currentDestinationString();
}
/* Methods related to Backup */
@Override
public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor inFd, int flags) {
return backupComponent.performIncrementalBackup(packageInfo, inFd, flags);
}
@Override
public int performBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
Log.w(TAG, "Warning: Legacy performBackup() method called.");
return performBackup(targetPackage, fileDescriptor, 0);
}
@Override
public int checkFullBackupSize(long size) {
return backupComponent.checkFullBackupSize(size);
}
@Override
public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket, int flags) {
// TODO handle flags
return performFullBackup(targetPackage, socket);
}
@Override
public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
return backupComponent.performFullBackup(targetPackage, fileDescriptor);
}
@Override
public int sendBackupData(int numBytes) {
return backupComponent.sendBackupData(numBytes);
}
@Override
public void cancelFullBackup() {
backupComponent.cancelFullBackup();
}
@Override
public int finishBackup() {
return backupComponent.finishBackup();
}
@Override
public long requestFullBackupTime() {
return backupComponent.requestFullBackupTime();
}
@Override
public long getBackupQuota(String packageName, boolean isFullBackup) {
return backupComponent.getBackupQuota(packageName, isFullBackup);
}
@Override
public int clearBackupData(PackageInfo packageInfo) {
return backupComponent.clearBackupData(packageInfo);
}
public void backupFinished() {
backupComponent.backupFinished();
}
/* Methods related to Restore */
@Override
public long getCurrentRestoreSet() {
return restoreComponent.getCurrentRestoreSet();
}
@Override
public int startRestore(long token, PackageInfo[] packages) {
return restoreComponent.startRestore(token, packages);
}
@Override
public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) {
return restoreComponent.getNextFullRestoreDataChunk(socket);
}
@Override
public RestoreSet[] getAvailableRestoreSets() {
return restoreComponent.getAvailableRestoreSets();
}
@Override
public RestoreDescription nextRestorePackage() {
return restoreComponent.nextRestorePackage();
}
@Override
public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
return restoreComponent.getRestoreData(outputFileDescriptor);
}
@Override
public int abortFullRestore() {
return restoreComponent.abortFullRestore();
}
@Override
public void finishRestore() {
restoreComponent.finishRestore();
}
}

View file

@ -1,43 +0,0 @@
package com.stevesoltys.backup.transport;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;
/**
* @author Steve Soltys
*/
public class ConfigurableBackupTransportService extends Service {
private static final String TAG = ConfigurableBackupTransportService.class.getName();
private static ConfigurableBackupTransport backupTransport = null;
public static ConfigurableBackupTransport getBackupTransport(Context context) {
if (backupTransport == null) {
backupTransport = new ConfigurableBackupTransport(context);
}
return backupTransport;
}
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "Service created.");
}
@Override
public IBinder onBind(Intent intent) {
return getBackupTransport(getApplicationContext()).getBinder();
}
@Override
public void onDestroy() {
super.onDestroy();
Log.d(TAG, "Service destroyed.");
}
}

View file

@ -1,38 +0,0 @@
package com.stevesoltys.backup.transport.component;
import android.content.pm.PackageInfo;
import android.os.ParcelFileDescriptor;
/**
* @author Steve Soltys
*/
public interface BackupComponent {
String currentDestinationString();
String dataManagementLabel();
int initializeDevice();
int clearBackupData(PackageInfo packageInfo);
int finishBackup();
int performIncrementalBackup(PackageInfo targetPackage, ParcelFileDescriptor data, int flags);
int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor);
int checkFullBackupSize(long size);
int sendBackupData(int numBytes);
void cancelFullBackup();
long getBackupQuota(String packageName, boolean fullBackup);
long requestBackupTime();
long requestFullBackupTime();
void backupFinished();
}

View file

@ -1,31 +0,0 @@
package com.stevesoltys.backup.transport.component;
import android.app.backup.RestoreDescription;
import android.app.backup.RestoreSet;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
/**
* @author Steve Soltys
*/
public interface RestoreComponent {
void prepareRestore(String password, Uri fileUri);
int startRestore(long token, PackageInfo[] packages);
RestoreDescription nextRestorePackage();
int getRestoreData(ParcelFileDescriptor outputFileDescriptor);
int getNextFullRestoreDataChunk(ParcelFileDescriptor socket);
int abortFullRestore();
long getCurrentRestoreSet();
void finishRestore();
RestoreSet[] getAvailableRestoreSets();
}

View file

@ -1,366 +0,0 @@
package com.stevesoltys.backup.transport.component.provider;
import android.app.backup.BackupDataInput;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Base64;
import android.util.Log;
import com.stevesoltys.backup.security.CipherUtil;
import com.stevesoltys.backup.security.KeyGenerator;
import com.stevesoltys.backup.settings.SettingsManager;
import com.stevesoltys.backup.transport.component.BackupComponent;
import org.apache.commons.io.IOUtils;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import libcore.io.IoUtils;
import static android.app.backup.BackupTransport.FLAG_INCREMENTAL;
import static android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL;
import static android.app.backup.BackupTransport.TRANSPORT_ERROR;
import static android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED;
import static android.app.backup.BackupTransport.TRANSPORT_OK;
import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED;
import static android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED;
import static android.provider.DocumentsContract.buildDocumentUriUsingTree;
import static android.provider.DocumentsContract.createDocument;
import static android.provider.DocumentsContract.getTreeDocumentId;
import static com.stevesoltys.backup.activity.MainActivityController.DOCUMENT_MIME_TYPE;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_BACKUP_QUOTA;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY;
import static java.util.Objects.requireNonNull;
/**
* @author Steve Soltys
*/
public class ContentProviderBackupComponent implements BackupComponent {
private static final String TAG = ContentProviderBackupComponent.class.getSimpleName();
private static final String DOCUMENT_SUFFIX = "yyyy-MM-dd_HH_mm_ss";
private static final String DESTINATION_DESCRIPTION = "Backing up to zip file";
private static final String TRANSPORT_DATA_MANAGEMENT_LABEL = "";
private static final int INITIAL_BUFFER_SIZE = 512;
private final Context context;
private ContentProviderBackupState backupState;
public ContentProviderBackupComponent(Context context) {
this.context = context;
}
@Override
public void cancelFullBackup() {
clearBackupState(false);
}
@Override
public int checkFullBackupSize(long size) {
int result = TRANSPORT_OK;
if (size <= 0) {
result = TRANSPORT_PACKAGE_REJECTED;
} else if (size > DEFAULT_BACKUP_QUOTA) {
result = TRANSPORT_QUOTA_EXCEEDED;
}
return result;
}
@Override
public int clearBackupData(PackageInfo packageInfo) {
return TRANSPORT_OK;
}
@Override
public String currentDestinationString() {
return DESTINATION_DESCRIPTION;
}
@Override
public String dataManagementLabel() {
return TRANSPORT_DATA_MANAGEMENT_LABEL;
}
@Override
public int finishBackup() {
return clearBackupState(false);
}
@Override
public long getBackupQuota(String packageName, boolean fullBackup) {
return DEFAULT_BACKUP_QUOTA;
}
@Override
public int initializeDevice() {
return TRANSPORT_OK;
}
@Override
public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
if (backupState != null && backupState.getInputFileDescriptor() != null) {
Log.e(TAG, "Attempt to initiate full backup while one is in progress");
return TRANSPORT_ERROR;
}
try {
initializeBackupState();
backupState.setPackageName(targetPackage.packageName);
backupState.setInputFileDescriptor(fileDescriptor);
backupState.setInputStream(new FileInputStream(fileDescriptor.getFileDescriptor()));
backupState.setBytesTransferred(0);
Cipher cipher = CipherUtil.startEncrypt(backupState.getSecretKey(), backupState.getSalt());
backupState.setCipher(cipher);
ZipEntry zipEntry = new ZipEntry(DEFAULT_FULL_BACKUP_DIRECTORY + backupState.getPackageName());
backupState.getOutputStream().putNextEntry(zipEntry);
} catch (Exception ex) {
Log.e(TAG, "Error creating backup file for " + targetPackage.packageName + ": ", ex);
clearBackupState(true);
return TRANSPORT_ERROR;
}
return TRANSPORT_OK;
}
@Override
public int performIncrementalBackup(PackageInfo packageInfo, ParcelFileDescriptor data, int flags) {
boolean isIncremental = (flags & FLAG_INCREMENTAL) != 0;
if (isIncremental) {
Log.w(TAG, "Can not handle incremental backup. Requesting non-incremental for " + packageInfo.packageName);
return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED;
}
boolean isNonIncremental = (flags & FLAG_NON_INCREMENTAL) != 0;
if (isNonIncremental) {
Log.i(TAG, "Performing non-incremental backup for " + packageInfo.packageName);
} else {
Log.i(TAG, "Performing backup for " + packageInfo.packageName);
}
BackupDataInput backupDataInput = new BackupDataInput(data.getFileDescriptor());
try {
initializeBackupState();
backupState.setPackageName(packageInfo.packageName);
return transferIncrementalBackupData(backupDataInput);
} catch (Exception ex) {
Log.e(TAG, "Error reading backup input: ", ex);
return TRANSPORT_ERROR;
}
}
@Override
public long requestBackupTime() {
return 0;
}
@Override
public long requestFullBackupTime() {
return 0;
}
@Override
public int sendBackupData(int numBytes) {
if (backupState == null) {
Log.e(TAG, "Attempted sendBackupData() before performFullBackup()");
return TRANSPORT_ERROR;
}
long bytesTransferred = backupState.getBytesTransferred() + numBytes;
if (bytesTransferred > DEFAULT_BACKUP_QUOTA) {
return TRANSPORT_QUOTA_EXCEEDED;
}
InputStream inputStream = backupState.getInputStream();
ZipOutputStream outputStream = backupState.getOutputStream();
try {
byte[] payload = IOUtils.readFully(inputStream, numBytes);
if (backupState.getCipher() != null) {
payload = backupState.getCipher().update(payload);
}
outputStream.write(payload, 0, numBytes);
backupState.setBytesTransferred(bytesTransferred);
} catch (Exception ex) {
Log.e(TAG, "Error handling backup data for " + backupState.getPackageName() + ": ", ex);
return TRANSPORT_ERROR;
}
return TRANSPORT_OK;
}
private int transferIncrementalBackupData(BackupDataInput backupDataInput) throws IOException {
ZipOutputStream outputStream = backupState.getOutputStream();
int bufferSize = INITIAL_BUFFER_SIZE;
byte[] buffer = new byte[bufferSize];
while (backupDataInput.readNextHeader()) {
String chunkFileName = Base64.encodeToString(backupDataInput.getKey().getBytes(), Base64.DEFAULT);
int dataSize = backupDataInput.getDataSize();
if (dataSize >= 0) {
ZipEntry zipEntry = new ZipEntry(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY +
backupState.getPackageName() + "/" + chunkFileName);
outputStream.putNextEntry(zipEntry);
if (dataSize > bufferSize) {
bufferSize = dataSize;
buffer = new byte[bufferSize];
}
backupDataInput.readEntityData(buffer, 0, dataSize);
try {
if (backupState.getSecretKey() != null) {
byte[] payload = Arrays.copyOfRange(buffer, 0, dataSize);
SecretKey secretKey = backupState.getSecretKey();
byte[] salt = backupState.getSalt();
outputStream.write(CipherUtil.encrypt(payload, secretKey, salt));
} else {
outputStream.write(buffer, 0, dataSize);
}
} catch (Exception ex) {
Log.e(TAG, "Error performing incremental backup for " + backupState.getPackageName() + ": ", ex);
clearBackupState(true);
return TRANSPORT_ERROR;
}
}
}
return TRANSPORT_OK;
}
@Override
public void backupFinished() {
clearBackupState(true);
}
private void initializeBackupState() throws Exception {
if (backupState == null) {
backupState = new ContentProviderBackupState();
}
if (backupState.getOutputStream() == null) {
initializeOutputStream();
ZipEntry saltZipEntry = new ZipEntry(ContentProviderBackupConstants.SALT_FILE_PATH);
backupState.getOutputStream().putNextEntry(saltZipEntry);
backupState.getOutputStream().write(backupState.getSalt());
backupState.getOutputStream().closeEntry();
String password = requireNonNull(SettingsManager.getBackupPassword(context));
backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt()));
}
}
private void initializeOutputStream() throws IOException {
Uri folderUri = SettingsManager.getBackupFolderUri(context);
// TODO notify about failure with notification
Uri fileUri = createBackupFile(folderUri);
ParcelFileDescriptor outputFileDescriptor = context.getContentResolver().openFileDescriptor(fileUri, "w");
if (outputFileDescriptor == null) throw new IOException();
backupState.setOutputFileDescriptor(outputFileDescriptor);
FileOutputStream fileOutputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);
backupState.setOutputStream(zipOutputStream);
}
private Uri createBackupFile(Uri folderUri) throws IOException {
Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri));
try {
Uri fileUri = createDocument(context.getContentResolver(), documentUri, DOCUMENT_MIME_TYPE, getBackupFileName());
if (fileUri == null) throw new IOException();
return fileUri;
} catch (SecurityException e) {
// happens when folder was deleted and thus Uri permission don't exist anymore
throw new IOException(e);
}
}
private String getBackupFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US);
String date = dateFormat.format(new Date());
return "backup-" + date;
}
private int clearBackupState(boolean closeFile) {
if (backupState == null) {
return TRANSPORT_OK;
}
try {
IoUtils.closeQuietly(backupState.getInputFileDescriptor());
backupState.setInputFileDescriptor(null);
ZipOutputStream outputStream = backupState.getOutputStream();
if (outputStream != null) {
if (backupState.getCipher() != null) {
outputStream.write(backupState.getCipher().doFinal());
backupState.setCipher(null);
}
outputStream.closeEntry();
}
if (closeFile) {
Log.d(TAG, "Closing backup file...");
if (outputStream != null) {
outputStream.finish();
outputStream.close();
}
IoUtils.closeQuietly(backupState.getOutputFileDescriptor());
backupState = null;
}
} catch (Exception ex) {
Log.e(TAG, "Error cancelling full backup: ", ex);
return TRANSPORT_ERROR;
}
return TRANSPORT_OK;
}
}

View file

@ -1,16 +0,0 @@
package com.stevesoltys.backup.transport.component.provider;
/**
* @author Steve Soltys
*/
public interface ContentProviderBackupConstants {
String SALT_FILE_PATH = "salt";
String DEFAULT_FULL_BACKUP_DIRECTORY = "full/";
String DEFAULT_INCREMENTAL_BACKUP_DIRECTORY = "incr/";
long DEFAULT_BACKUP_QUOTA = Long.MAX_VALUE;
}

View file

@ -1,109 +0,0 @@
package com.stevesoltys.backup.transport.component.provider;
import android.os.ParcelFileDescriptor;
import java.io.InputStream;
import java.security.SecureRandom;
import java.util.zip.ZipOutputStream;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
/**
* @author Steve Soltys
*/
class ContentProviderBackupState {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private ParcelFileDescriptor inputFileDescriptor;
private ParcelFileDescriptor outputFileDescriptor;
private InputStream inputStream;
private ZipOutputStream outputStream;
private Cipher cipher;
private long bytesTransferred;
private String packageName;
private byte[] salt;
private SecretKey secretKey;
ContentProviderBackupState() {
salt = new byte[16];
SECURE_RANDOM.nextBytes(salt);
}
long getBytesTransferred() {
return bytesTransferred;
}
void setBytesTransferred(long bytesTransferred) {
this.bytesTransferred = bytesTransferred;
}
Cipher getCipher() {
return cipher;
}
void setCipher(Cipher cipher) {
this.cipher = cipher;
}
ParcelFileDescriptor getInputFileDescriptor() {
return inputFileDescriptor;
}
void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
this.inputFileDescriptor = inputFileDescriptor;
}
InputStream getInputStream() {
return inputStream;
}
void setInputStream(InputStream inputStream) {
this.inputStream = inputStream;
}
ParcelFileDescriptor getOutputFileDescriptor() {
return outputFileDescriptor;
}
void setOutputFileDescriptor(ParcelFileDescriptor outputFileDescriptor) {
this.outputFileDescriptor = outputFileDescriptor;
}
ZipOutputStream getOutputStream() {
return outputStream;
}
void setOutputStream(ZipOutputStream outputStream) {
this.outputStream = outputStream;
}
String getPackageName() {
return packageName;
}
void setPackageName(String packageName) {
this.packageName = packageName;
}
byte[] getSalt() {
return salt;
}
SecretKey getSecretKey() {
return secretKey;
}
void setSecretKey(SecretKey secretKey) {
this.secretKey = secretKey;
}
}

View file

@ -1,360 +0,0 @@
package com.stevesoltys.backup.transport.component.provider;
import android.annotation.Nullable;
import android.app.backup.BackupDataOutput;
import android.app.backup.RestoreDescription;
import android.app.backup.RestoreSet;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Base64;
import android.util.Log;
import com.android.internal.util.Preconditions;
import com.stevesoltys.backup.security.CipherUtil;
import com.stevesoltys.backup.security.KeyGenerator;
import com.stevesoltys.backup.transport.component.RestoreComponent;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.crypto.SecretKey;
import libcore.io.IoUtils;
import libcore.io.Streams;
import static android.app.backup.BackupTransport.NO_MORE_DATA;
import static android.app.backup.BackupTransport.TRANSPORT_ERROR;
import static android.app.backup.BackupTransport.TRANSPORT_OK;
import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED;
import static android.app.backup.RestoreDescription.TYPE_FULL_STREAM;
import static android.app.backup.RestoreDescription.TYPE_KEY_VALUE;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY;
import static java.util.Objects.requireNonNull;
/**
* @author Steve Soltys
*/
public class ContentProviderRestoreComponent implements RestoreComponent {
private static final String TAG = ContentProviderRestoreComponent.class.getName();
private static final int DEFAULT_RESTORE_SET = 1;
private static final int DEFAULT_BUFFER_SIZE = 2048;
@Nullable
private String password;
@Nullable
private Uri fileUri;
private ContentProviderRestoreState restoreState;
private final Context context;
public ContentProviderRestoreComponent(Context context) {
this.context = context;
}
@Override
public void prepareRestore(String password, Uri fileUri) {
this.password = password;
this.fileUri = fileUri;
}
@Override
public int startRestore(long token, PackageInfo[] packages) {
restoreState = new ContentProviderRestoreState();
restoreState.setPackages(packages);
restoreState.setPackageIndex(-1);
String password = requireNonNull(this.password);
if (!password.isEmpty()) {
try {
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
seekToEntry(inputStream, ContentProviderBackupConstants.SALT_FILE_PATH);
restoreState.setSalt(Streams.readFullyNoClose(inputStream));
restoreState.setSecretKey(KeyGenerator.generate(password, restoreState.getSalt()));
IoUtils.closeQuietly(inputFileDescriptor);
IoUtils.closeQuietly(inputStream);
} catch (Exception ex) {
Log.e(TAG, "Salt not found", ex);
}
}
try {
List<ZipEntry> zipEntries = new LinkedList<>();
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
ZipEntry zipEntry;
while ((zipEntry = inputStream.getNextEntry()) != null) {
zipEntries.add(zipEntry);
inputStream.closeEntry();
}
IoUtils.closeQuietly(inputFileDescriptor);
IoUtils.closeQuietly(inputStream);
restoreState.setZipEntries(zipEntries);
} catch (Exception ex) {
Log.e(TAG, "Error while caching zip entries", ex);
}
return TRANSPORT_OK;
}
@Override
public RestoreDescription nextRestorePackage() {
Preconditions.checkNotNull(restoreState, "startRestore() not called");
int packageIndex = restoreState.getPackageIndex();
PackageInfo[] packages = restoreState.getPackages();
while (++packageIndex < packages.length) {
restoreState.setPackageIndex(packageIndex);
String name = packages[packageIndex].packageName;
if (containsPackageFile(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + name)) {
restoreState.setRestoreType(TYPE_KEY_VALUE);
return new RestoreDescription(name, restoreState.getRestoreType());
} else if (containsPackageFile(DEFAULT_FULL_BACKUP_DIRECTORY + name)) {
restoreState.setRestoreType(TYPE_FULL_STREAM);
return new RestoreDescription(name, restoreState.getRestoreType());
}
}
return RestoreDescription.NO_MORE_PACKAGES;
}
private boolean containsPackageFile(String fileName) {
return restoreState.getZipEntries().stream()
.anyMatch(zipEntry -> zipEntry.getName().startsWith(fileName));
}
@Override
public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
Preconditions.checkState(restoreState != null, "startRestore() not called");
Preconditions.checkState(restoreState.getPackageIndex() >= 0, "nextRestorePackage() not called");
Preconditions.checkState(restoreState.getRestoreType() == TYPE_KEY_VALUE,
"getRestoreData() for non-key/value dataset");
PackageInfo packageInfo = restoreState.getPackages()[restoreState.getPackageIndex()];
try {
return transferIncrementalRestoreData(packageInfo.packageName, outputFileDescriptor);
} catch (Exception ex) {
Log.e(TAG, "Unable to read backup records: ", ex);
return TRANSPORT_ERROR;
}
}
private int transferIncrementalRestoreData(String packageName, ParcelFileDescriptor outputFileDescriptor)
throws Exception {
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
BackupDataOutput backupDataOutput = new BackupDataOutput(outputFileDescriptor.getFileDescriptor());
Optional<ZipEntry> zipEntryOptional = seekToEntry(inputStream,
DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
while (zipEntryOptional.isPresent()) {
String fileName = new File(zipEntryOptional.get().getName()).getName();
String blobKey = new String(Base64.decode(fileName, Base64.DEFAULT));
byte[] backupData = readBackupData(inputStream);
backupDataOutput.writeEntityHeader(blobKey, backupData.length);
backupDataOutput.writeEntityData(backupData, backupData.length);
inputStream.closeEntry();
zipEntryOptional = seekToEntry(inputStream, DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
}
IoUtils.closeQuietly(inputFileDescriptor);
IoUtils.closeQuietly(outputFileDescriptor);
return TRANSPORT_OK;
}
private byte[] readBackupData(ZipInputStream inputStream) throws Exception {
byte[] backupData = Streams.readFullyNoClose(inputStream);
SecretKey secretKey = restoreState.getSecretKey();
byte[] initializationVector = restoreState.getSalt();
if (secretKey != null) {
backupData = CipherUtil.decrypt(backupData, secretKey, initializationVector);
}
return backupData;
}
@Override
public int getNextFullRestoreDataChunk(ParcelFileDescriptor outputFileDescriptor) {
Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM,
"Asked for full restore data for non-stream package");
ParcelFileDescriptor inputFileDescriptor = restoreState.getInputFileDescriptor();
if (inputFileDescriptor == null) {
String name = restoreState.getPackages()[restoreState.getPackageIndex()].packageName;
try {
inputFileDescriptor = buildInputFileDescriptor();
restoreState.setInputFileDescriptor(inputFileDescriptor);
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
restoreState.setInputStream(inputStream);
if (!seekToEntry(inputStream, DEFAULT_FULL_BACKUP_DIRECTORY + name).isPresent()) {
IoUtils.closeQuietly(inputFileDescriptor);
IoUtils.closeQuietly(outputFileDescriptor);
return TRANSPORT_PACKAGE_REJECTED;
}
} catch (IOException ex) {
Log.e(TAG, "Unable to read archive for " + name, ex);
IoUtils.closeQuietly(inputFileDescriptor);
IoUtils.closeQuietly(outputFileDescriptor);
return TRANSPORT_PACKAGE_REJECTED;
}
}
return transferFullRestoreData(outputFileDescriptor);
}
private int transferFullRestoreData(ParcelFileDescriptor outputFileDescriptor) {
ZipInputStream inputStream = restoreState.getInputStream();
OutputStream outputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int bytesRead = NO_MORE_DATA;
try {
bytesRead = inputStream.read(buffer);
if (bytesRead <= 0) {
bytesRead = NO_MORE_DATA;
if (restoreState.getCipher() != null) {
buffer = restoreState.getCipher().doFinal();
bytesRead = buffer.length;
outputStream.write(buffer, 0, bytesRead);
restoreState.setCipher(null);
}
} else {
if (restoreState.getSecretKey() != null) {
SecretKey secretKey = restoreState.getSecretKey();
byte[] salt = restoreState.getSalt();
if (restoreState.getCipher() == null) {
restoreState.setCipher(CipherUtil.startDecrypt(secretKey, salt));
}
buffer = restoreState.getCipher().update(Arrays.copyOfRange(buffer, 0, bytesRead));
bytesRead = buffer.length;
}
outputStream.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
Log.e(TAG, "Exception while streaming restore data: ", e);
return TRANSPORT_ERROR;
} finally {
if (bytesRead == NO_MORE_DATA) {
if (restoreState.getInputFileDescriptor() != null) {
IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
}
restoreState.setInputFileDescriptor(null);
restoreState.setInputStream(null);
}
IoUtils.closeQuietly(outputFileDescriptor);
}
return bytesRead;
}
@Override
public int abortFullRestore() {
resetFullRestoreState();
return TRANSPORT_OK;
}
@Override
public long getCurrentRestoreSet() {
return DEFAULT_RESTORE_SET;
}
@Override
public void finishRestore() {
if (restoreState.getRestoreType() == TYPE_FULL_STREAM) {
resetFullRestoreState();
}
restoreState = null;
}
@Override
public RestoreSet[] getAvailableRestoreSets() {
return new RestoreSet[]{new RestoreSet("Local disk image", "flash", DEFAULT_RESTORE_SET)};
}
private void resetFullRestoreState() {
Preconditions.checkNotNull(restoreState);
Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM);
IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
restoreState = null;
}
private ParcelFileDescriptor buildInputFileDescriptor() throws FileNotFoundException {
ContentResolver contentResolver = context.getContentResolver();
return contentResolver.openFileDescriptor(requireNonNull(fileUri), "r");
}
private ZipInputStream buildInputStream(ParcelFileDescriptor inputFileDescriptor) {
FileInputStream fileInputStream = new FileInputStream(inputFileDescriptor.getFileDescriptor());
return new ZipInputStream(fileInputStream);
}
private Optional<ZipEntry> seekToEntry(ZipInputStream inputStream, String entryPath) throws IOException {
ZipEntry zipEntry;
while ((zipEntry = inputStream.getNextEntry()) != null) {
if (zipEntry.getName().startsWith(entryPath)) {
return Optional.of(zipEntry);
}
inputStream.closeEntry();
}
return Optional.empty();
}
}

View file

@ -1,106 +0,0 @@
package com.stevesoltys.backup.transport.component.provider;
import android.content.pm.PackageInfo;
import android.os.ParcelFileDescriptor;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* @author Steve Soltys
*/
class ContentProviderRestoreState {
private ParcelFileDescriptor inputFileDescriptor;
private PackageInfo[] packages;
private int packageIndex;
private int restoreType;
private ZipInputStream inputStream;
private Cipher cipher;
private byte[] salt;
private SecretKey secretKey;
private List<ZipEntry> zipEntries;
Cipher getCipher() {
return cipher;
}
ParcelFileDescriptor getInputFileDescriptor() {
return inputFileDescriptor;
}
void setCipher(Cipher cipher) {
this.cipher = cipher;
}
void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
this.inputFileDescriptor = inputFileDescriptor;
}
ZipInputStream getInputStream() {
return inputStream;
}
void setInputStream(ZipInputStream inputStream) {
this.inputStream = inputStream;
}
int getPackageIndex() {
return packageIndex;
}
void setPackageIndex(int packageIndex) {
this.packageIndex = packageIndex;
}
PackageInfo[] getPackages() {
return packages;
}
void setPackages(PackageInfo[] packages) {
this.packages = packages;
}
int getRestoreType() {
return restoreType;
}
void setRestoreType(int restoreType) {
this.restoreType = restoreType;
}
byte[] getSalt() {
return salt;
}
void setSalt(byte[] salt) {
this.salt = salt;
}
public SecretKey getSecretKey() {
return secretKey;
}
public void setSecretKey(SecretKey secretKey) {
this.secretKey = secretKey;
}
public List<ZipEntry> getZipEntries() {
return zipEntries;
}
public void setZipEntries(List<ZipEntry> zipEntries) {
this.zipEntries = zipEntries;
}
}

View file

@ -0,0 +1,66 @@
package com.stevesoltys.seedvault
import android.app.Application
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE
import android.os.Build
import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel
import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.transport.restore.restoreModule
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.dsl.module
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class App : Application() {
private val appModule = module {
single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
viewModel { SettingsViewModel(this@App, get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get()) }
}
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@App)
modules(listOf(
cryptoModule,
headerModule,
metadataModule,
documentsProviderModule, // storage plugin
backupModule,
restoreModule,
appModule
))
}
}
}
const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL
fun isDebugBuild() = Build.TYPE == "userdebug"

View file

@ -0,0 +1,20 @@
package com.stevesoltys.seedvault
import android.app.backup.BackupManagerMonitor.*
import android.app.backup.IBackupManagerMonitor
import android.os.Bundle
import android.util.Log
import android.util.Log.DEBUG
private val TAG = BackupMonitor::class.java.name
class BackupMonitor : IBackupManagerMonitor.Stub() {
override fun onEvent(bundle: Bundle) {
if (!Log.isLoggable(TAG, DEBUG)) return
Log.d(TAG, "ID: " + bundle.getInt(EXTRA_LOG_EVENT_ID))
Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1))
Log.d(TAG, "PACKAGE: " + bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?"))
}
}

View file

@ -0,0 +1,97 @@
package com.stevesoltys.seedvault
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.PendingIntent
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat.*
import com.stevesoltys.seedvault.settings.SettingsActivity
import java.util.*
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
private const val CHANNEL_ID_ERROR = "NotificationError"
private const val NOTIFICATION_ID_OBSERVER = 1
private const val NOTIFICATION_ID_ERROR = 2
class BackupNotificationManager(private val context: Context) {
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
createNotificationChannel(getObserverChannel())
createNotificationChannel(getErrorChannel())
}
private fun getObserverChannel(): NotificationChannel {
val title = context.getString(R.string.notification_channel_title)
return NotificationChannel(CHANNEL_ID_OBSERVER, title, IMPORTANCE_LOW).apply {
enableVibration(false)
}
}
private fun getErrorChannel(): NotificationChannel {
val title = context.getString(R.string.notification_error_channel_title)
return NotificationChannel(CHANNEL_ID_ERROR, title, IMPORTANCE_DEFAULT)
}
private val observerBuilder = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload)
}
private val errorBuilder = Builder(context, CHANNEL_ID_ERROR).apply {
setSmallIcon(R.drawable.ic_cloud_error)
}
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) {
val notification = observerBuilder.apply {
setContentTitle(context.getString(R.string.notification_title))
setContentText(app)
setWhen(Date().time)
setProgress(expected, transferred, false)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
}
fun onBackupResult(app: CharSequence, status: Int, userInitiated: Boolean) {
val title = context.getString(when (status) {
0 -> R.string.notification_backup_result_complete
TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected
else -> R.string.notification_backup_result_error
})
val notification = observerBuilder.apply {
setContentTitle(title)
setContentText(app)
setWhen(Date().time)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
}
fun onBackupFinished() {
nm.cancel(NOTIFICATION_ID_OBSERVER)
}
fun onBackupError() {
val intent = Intent(context, SettingsActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val actionText = context.getString(R.string.notification_error_action)
val action = Action(R.drawable.ic_storage, actionText, pendingIntent)
val notification = errorBuilder.apply {
setContentTitle(context.getString(R.string.notification_error_title))
setContentText(context.getString(R.string.notification_error_text))
setWhen(Date().time)
setOnlyAlertOnce(true)
setAutoCancel(true)
mActions = arrayListOf(action)
}.build()
nm.notify(NOTIFICATION_ID_ERROR, notification)
}
fun onBackupErrorSeen() {
nm.cancel(NOTIFICATION_ID_ERROR)
}
}

View file

@ -0,0 +1,18 @@
package com.stevesoltys.seedvault
import java.nio.charset.Charset
import java.util.*
val Utf8: Charset = Charset.forName("UTF-8")
fun ByteArray.encodeBase64(): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(this)
}
fun String.encodeBase64(): String {
return toByteArray(Utf8).encodeBase64()
}
fun String.decodeBase64(): String {
return String(Base64.getUrlDecoder().decode(this))
}

View file

@ -0,0 +1,76 @@
package com.stevesoltys.seedvault
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import android.util.Log.INFO
import android.util.Log.isLoggable
import org.koin.core.KoinComponent
import org.koin.core.inject
private val TAG = NotificationBackupObserver::class.java.simpleName
class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent {
private val pm = context.packageManager
private val nm: BackupNotificationManager by inject()
/**
* This method could be called several times for packages with full data backup.
* It will tell how much of backup data is already saved and how much is expected.
*
* @param currentBackupPackage The name of the package that now being backed up.
* @param backupProgress Current progress of backup for the package.
*/
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
val transferred = backupProgress.bytesTransferred.toInt()
val expected = backupProgress.bytesExpected.toInt()
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
}
val app = getAppName(currentBackupPackage)
nm.onBackupUpdate(app, transferred, expected, userInitiated)
}
/**
* Backup of one package or initialization of one transport has completed. This
* method will be called at most one time for each package or transport, and might not
* be not called if the operation fails before backupFinished(); for example, if the
* requested package/transport does not exist.
*
* @param target The name of the package that was backed up, or of the transport
* that was initialized
* @param status Zero on success; a nonzero error code if the backup operation failed.
*/
override fun onResult(target: String, status: Int) {
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Completed. Target: $target, status: $status")
}
nm.onBackupResult(getAppName(target), status, userInitiated)
}
/**
* The backup process has completed. This method will always be called,
* even if no individual package backup operations were attempted.
*
* @param status Zero on success; a nonzero error code if the backup operation
* as a whole failed.
*/
override fun backupFinished(status: Int) {
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Backup finished. Status: $status")
}
nm.onBackupFinished()
}
private fun getAppName(packageId: String): CharSequence = getAppName(pm, packageId)
}
fun getAppName(pm: PackageManager, packageId: String): CharSequence {
if (packageId == MAGIC_PACKAGE_MANAGER) return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
}

View file

@ -0,0 +1,108 @@
package com.stevesoltys.seedvault
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager.*
import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.util.Log
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.*
import java.util.concurrent.TimeUnit.HOURS
private val TAG = UsbIntentReceiver::class.java.simpleName
class UsbIntentReceiver : UsbMonitor(), KoinComponent {
private val settingsManager by inject<SettingsManager>()
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
if (action != ACTION_USB_DEVICE_ATTACHED) return false
Log.d(TAG, "Checking if this is the current backup drive.")
val savedFlashDrive = settingsManager.getFlashDrive() ?: return false
val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...")
if (Date().time - settingsManager.getBackupTime() >= HOURS.toMillis(24)) {
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...")
true
} else {
Log.d(TAG, "We have a recent backup, not requesting a new one.")
false
}
} else {
Log.d(TAG, "Different device attached, ignoring...")
false
}
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
Thread {
requestBackup(context)
}.start()
}
}
/**
* When we get the [ACTION_USB_DEVICE_ATTACHED] broadcast, the storage is not yet available.
* So we need to use a ContentObserver to request a backup only once available.
*/
abstract class UsbMonitor : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (intent.action == ACTION_USB_DEVICE_ATTACHED || intent.action == ACTION_USB_DEVICE_DETACHED) {
val device = intent.extras?.getParcelable<UsbDevice>(EXTRA_DEVICE) ?: return
Log.d(TAG, "New USB mass-storage device attached.")
device.log()
if (!shouldMonitorStatus(context, action, device)) return
val rootsUri = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
val contentResolver = context.contentResolver
val observer = object : ContentObserver(Handler()) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
onStatusChanged(context, action, device)
contentResolver.unregisterContentObserver(this)
}
}
contentResolver.registerContentObserver(rootsUri, true, observer)
}
}
abstract fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean
abstract fun onStatusChanged(context: Context, action: String, device: UsbDevice)
}
internal fun UsbDevice.isMassStorage(): Boolean {
for (i in 0 until interfaceCount) {
if (getInterface(i).isMassStorage()) return true
}
return false
}
private fun UsbInterface.isMassStorage(): Boolean {
return interfaceClass == 8 && interfaceProtocol == 80 && interfaceSubclass == 6
}
private fun UsbDevice.log() {
Log.d(TAG, " name: $manufacturerName $productName")
Log.d(TAG, " serialNumber: $serialNumber")
Log.d(TAG, " productId: $productId")
Log.d(TAG, " vendorId: $vendorId")
Log.d(TAG, " isMassStorage: ${isMassStorage()}")
}

View file

@ -0,0 +1,31 @@
package com.stevesoltys.seedvault.crypto
import javax.crypto.Cipher
import javax.crypto.Cipher.DECRYPT_MODE
import javax.crypto.Cipher.ENCRYPT_MODE
import javax.crypto.spec.GCMParameterSpec
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
internal const val GCM_AUTHENTICATION_TAG_LENGTH = 128
interface CipherFactory {
fun createEncryptionCipher(): Cipher
fun createDecryptionCipher(iv: ByteArray): Cipher
}
internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory {
override fun createEncryptionCipher(): Cipher {
return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
init(ENCRYPT_MODE, keyManager.getBackupKey())
}
}
override fun createDecryptionCipher(iv: ByteArray): Cipher {
return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
val spec = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv)
init(DECRYPT_MODE, keyManager.getBackupKey(), spec)
}
}
}

View file

@ -0,0 +1,185 @@
package com.stevesoltys.seedvault.crypto
import com.stevesoltys.seedvault.header.*
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.Cipher
import kotlin.math.min
/**
* A backup stream starts with a version byte followed by an encrypted [VersionHeader].
*
* The header will be encrypted with AES/GCM to provide authentication.
* It can be written using [encryptHeader] and read using [decryptHeader].
* The latter throws a [SecurityException],
* if the expected version and package name do not match the encrypted header.
*
* After the header, follows one or more data segments.
* Each segment begins with a clear-text [SegmentHeader]
* that contains the length of the segment
* and a nonce acting as the initialization vector for the encryption.
* The segment can be written using [encryptSegment] and read using [decryptSegment].
* The latter throws a [SecurityException],
* if the length of the segment is specified larger than allowed.
*/
interface Crypto {
/**
* Encrypts a backup stream header ([VersionHeader]) and writes it to the given [OutputStream].
*
* The header using a small segment containing only
* the version number, the package name and (optionally) the key of a key/value stream.
*/
@Throws(IOException::class)
fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader)
/**
* Encrypts a new backup segment from the given cleartext payload
* and writes it to the given [OutputStream].
*
* A segment starts with a [SegmentHeader] which includes the length of the segment
* and a nonce which is used as initialization vector for the encryption.
*
* After the header follows the encrypted payload.
* Larger backup streams such as from a full backup are encrypted in multiple segments
* to avoid having to load the entire stream into memory when doing authenticated encryption.
*
* The given cleartext can later be decrypted
* by calling [decryptSegment] on the same byte stream.
*/
@Throws(IOException::class)
fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray)
/**
* Like [encryptSegment],
* but if the given cleartext [ByteArray] is larger than [MAX_SEGMENT_CLEARTEXT_LENGTH],
* multiple segments will be written.
*/
@Throws(IOException::class)
fun encryptMultipleSegments(outputStream: OutputStream, cleartext: ByteArray)
/**
* Reads and decrypts a [VersionHeader] from the given [InputStream]
* and ensures that the expected version, package name and key match
* what is found in the header.
* If a mismatch is found, a [SecurityException] is thrown.
*
* @return The read [VersionHeader] present in the beginning of the given [InputStream].
*/
@Throws(IOException::class, SecurityException::class)
fun decryptHeader(inputStream: InputStream, expectedVersion: Byte, expectedPackageName: String,
expectedKey: String? = null): VersionHeader
/**
* Reads and decrypts a segment from the given [InputStream].
*
* @return The decrypted segment payload as passed into [encryptSegment]
*/
@Throws(EOFException::class, IOException::class, SecurityException::class)
fun decryptSegment(inputStream: InputStream): ByteArray
/**
* Like [decryptSegment], but decrypts multiple segments and does not throw [EOFException].
*/
@Throws(IOException::class, SecurityException::class)
fun decryptMultipleSegments(inputStream: InputStream): ByteArray
}
internal class CryptoImpl(
private val cipherFactory: CipherFactory,
private val headerWriter: HeaderWriter,
private val headerReader: HeaderReader) : Crypto {
@Throws(IOException::class)
override fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader) {
val bytes = headerWriter.getEncodedVersionHeader(versionHeader)
encryptSegment(outputStream, bytes)
}
@Throws(IOException::class)
override fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray) {
val cipher = cipherFactory.createEncryptionCipher()
check(cipher.getOutputSize(cleartext.size) <= MAX_SEGMENT_LENGTH) {
"Cipher's output size ${cipher.getOutputSize(cleartext.size)} is larger than maximum segment length ($MAX_SEGMENT_LENGTH)"
}
encryptSegment(cipher, outputStream, cleartext)
}
@Throws(IOException::class)
override fun encryptMultipleSegments(outputStream: OutputStream, cleartext: ByteArray) {
var end = 0
while (end < cleartext.size) {
val start = end
end = min(cleartext.size, start + MAX_SEGMENT_CLEARTEXT_LENGTH)
val segment = cleartext.copyOfRange(start, end)
val cipher = cipherFactory.createEncryptionCipher()
encryptSegment(cipher, outputStream, segment)
}
}
@Throws(IOException::class)
private fun encryptSegment(cipher: Cipher, outputStream: OutputStream, segment: ByteArray) {
val encrypted = cipher.doFinal(segment)
val segmentHeader = SegmentHeader(encrypted.size.toShort(), cipher.iv)
headerWriter.writeSegmentHeader(outputStream, segmentHeader)
outputStream.write(encrypted)
}
@Throws(IOException::class, SecurityException::class)
override fun decryptHeader(inputStream: InputStream, expectedVersion: Byte,
expectedPackageName: String, expectedKey: String?): VersionHeader {
val decrypted = decryptSegment(inputStream, MAX_VERSION_HEADER_SIZE)
val header = headerReader.getVersionHeader(decrypted)
if (header.version != expectedVersion) {
throw SecurityException("Invalid version '${header.version.toInt()}' in header, expected '${expectedVersion.toInt()}'.")
}
if (header.packageName != expectedPackageName) {
throw SecurityException("Invalid package name '${header.packageName}' in header, expected '$expectedPackageName'.")
}
if (header.key != expectedKey) {
throw SecurityException("Invalid key '${header.key}' in header, expected '$expectedKey'.")
}
return header
}
@Throws(EOFException::class, IOException::class, SecurityException::class)
override fun decryptSegment(inputStream: InputStream): ByteArray {
return decryptSegment(inputStream, MAX_SEGMENT_LENGTH)
}
@Throws(IOException::class, SecurityException::class)
override fun decryptMultipleSegments(inputStream: InputStream): ByteArray {
var result = ByteArray(0)
while (true) {
try {
result += decryptSegment(inputStream, MAX_SEGMENT_LENGTH)
} catch (e: EOFException) {
if (result.isEmpty()) throw IOException(e)
return result
}
}
}
@Throws(EOFException::class, IOException::class, SecurityException::class)
private fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray {
val segmentHeader = headerReader.readSegmentHeader(inputStream)
if (segmentHeader.segmentLength > maxSegmentLength) {
throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength")
}
val buffer = ByteArray(segmentHeader.segmentLength.toInt())
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) throw EOFException()
if (bytesRead != buffer.size) throw IOException()
val cipher = cipherFactory.createDecryptionCipher(segmentHeader.nonce)
return cipher.doFinal(buffer)
}
}

View file

@ -0,0 +1,9 @@
package com.stevesoltys.seedvault.crypto
import org.koin.dsl.module
val cryptoModule = module {
factory<CipherFactory> { CipherFactoryImpl(get()) }
single<KeyManager> { KeyManagerImpl() }
single<Crypto> { CryptoImpl(get(), get(), get()) }
}

View file

@ -0,0 +1,72 @@
package com.stevesoltys.seedvault.crypto
import android.os.Build.VERSION.SDK_INT
import android.security.keystore.KeyProperties.*
import android.security.keystore.KeyProtection
import java.security.KeyStore
import java.security.KeyStore.SecretKeyEntry
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
internal const val KEY_SIZE = 256
private const val KEY_SIZE_BYTES = KEY_SIZE / 8
private const val KEY_ALIAS = "com.stevesoltys.seedvault"
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
interface KeyManager {
/**
* Store a new backup key derived from the given [seed].
*
* The seed needs to be larger or equal to [KEY_SIZE_BYTES].
*/
fun storeBackupKey(seed: ByteArray)
/**
* @return true if a backup key already exists in the [KeyStore].
*/
fun hasBackupKey(): Boolean
/**
* Returns the backup key, so it can be used for encryption or decryption.
*
* Note that any attempt to export the key will return null or an empty [ByteArray],
* because the key can not leave the [KeyStore]'s hardware security module.
*/
fun getBackupKey(): SecretKey
}
internal class KeyManagerImpl : KeyManager {
private val keyStore by lazy {
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
load(null)
}
}
override fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
// TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe!
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
val ksEntry = SecretKeyEntry(secretKeySpec)
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
}
override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
override fun getBackupKey(): SecretKey {
val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
return ksEntry.secretKey
}
private fun getKeyProtection(): KeyProtection {
val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
// unlocking is required only for decryption, so when restoring from backup
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
return builder.build()
}
}

View file

@ -0,0 +1,48 @@
package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.crypto.GCM_AUTHENTICATION_TAG_LENGTH
internal const val VERSION: Byte = 0
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
internal const val MAX_VERSION_HEADER_SIZE = 1 + Short.SIZE_BYTES * 2 + MAX_PACKAGE_LENGTH_SIZE + MAX_KEY_LENGTH_SIZE
/**
* After the first version byte of each backup stream
* must follow followed this header encrypted with authentication.
*/
data class VersionHeader(
internal val version: Byte = VERSION, // 1 byte
internal val packageName: String, // ?? bytes (max 255)
internal val key: String? = null // ?? bytes
) {
init {
check(packageName.length <= MAX_PACKAGE_LENGTH_SIZE) {
"Package $packageName has name longer than $MAX_PACKAGE_LENGTH_SIZE"
}
key?.let {
check(key.length <= MAX_KEY_LENGTH_SIZE) { "Key $key is longer than $MAX_KEY_LENGTH_SIZE" }
}
}
}
internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES
internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt()
internal const val MAX_SEGMENT_CLEARTEXT_LENGTH: Int = MAX_SEGMENT_LENGTH - GCM_AUTHENTICATION_TAG_LENGTH / 8
internal const val IV_SIZE: Int = 12
internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE
/**
* Each data segment must start with this header
*/
class SegmentHeader(
internal val segmentLength: Short, // 2 bytes
internal val nonce: ByteArray // 12 bytes
) {
init {
check(nonce.size == IV_SIZE) {
"Nonce size of ${nonce.size} is not the expected IV size of $IV_SIZE"
}
}
}

View file

@ -0,0 +1,8 @@
package com.stevesoltys.seedvault.header
import org.koin.dsl.module
val headerModule = module {
single<HeaderWriter> { HeaderWriterImpl() }
single<HeaderReader> { HeaderReaderImpl() }
}

View file

@ -0,0 +1,73 @@
package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.Utf8
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
interface HeaderReader {
@Throws(IOException::class, UnsupportedVersionException::class)
fun readVersion(inputStream: InputStream): Byte
@Throws(SecurityException::class)
fun getVersionHeader(byteArray: ByteArray): VersionHeader
@Throws(EOFException::class, IOException::class)
fun readSegmentHeader(inputStream: InputStream): SegmentHeader
}
internal class HeaderReaderImpl : HeaderReader {
@Throws(IOException::class, UnsupportedVersionException::class)
override fun readVersion(inputStream: InputStream): Byte {
val version = inputStream.read().toByte()
if (version < 0) throw IOException()
if (version > VERSION) throw UnsupportedVersionException(version)
return version
}
override fun getVersionHeader(byteArray: ByteArray): VersionHeader {
val buffer = ByteBuffer.wrap(byteArray)
val version = buffer.get()
val packageLength = buffer.short.toInt()
if (packageLength <= 0) throw SecurityException("Invalid package length: $packageLength")
if (packageLength > MAX_PACKAGE_LENGTH_SIZE) throw SecurityException("Too large package length: $packageLength")
if (packageLength > buffer.remaining()) throw SecurityException("Not enough bytes for package name")
val packageName = ByteArray(packageLength)
.apply { buffer.get(this) }
.toString(Utf8)
val keyLength = buffer.short.toInt()
if (keyLength < 0) throw SecurityException("Invalid key length: $keyLength")
if (keyLength > MAX_KEY_LENGTH_SIZE) throw SecurityException("Too large key length: $keyLength")
if (keyLength > buffer.remaining()) throw SecurityException("Not enough bytes for key")
val key = if (keyLength == 0) null else ByteArray(keyLength)
.apply { buffer.get(this) }
.toString(Utf8)
if (buffer.remaining() != 0) throw SecurityException("Found extra bytes in header")
return VersionHeader(version, packageName, key)
}
@Throws(EOFException::class, IOException::class)
override fun readSegmentHeader(inputStream: InputStream): SegmentHeader {
val buffer = ByteArray(SEGMENT_HEADER_SIZE)
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) throw EOFException()
if (bytesRead != SEGMENT_HEADER_SIZE) {
throw IOException("Read $bytesRead bytes, but expected $SEGMENT_HEADER_SIZE")
}
val segmentLength = ByteBuffer.wrap(buffer, 0, SEGMENT_LENGTH_SIZE).short
if (segmentLength <= 0) throw IOException()
val nonce = buffer.copyOfRange(SEGMENT_LENGTH_SIZE, buffer.size)
return SegmentHeader(segmentLength, nonce)
}
}
class UnsupportedVersionException(val version: Byte) : IOException()

View file

@ -0,0 +1,51 @@
package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.Utf8
import java.io.IOException
import java.io.OutputStream
import java.nio.ByteBuffer
interface HeaderWriter {
@Throws(IOException::class)
fun writeVersion(outputStream: OutputStream, header: VersionHeader)
fun getEncodedVersionHeader(header: VersionHeader): ByteArray
@Throws(IOException::class)
fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader)
}
internal class HeaderWriterImpl : HeaderWriter {
@Throws(IOException::class)
override fun writeVersion(outputStream: OutputStream, header: VersionHeader) {
val headerBytes = ByteArray(1)
headerBytes[0] = header.version
outputStream.write(headerBytes)
}
override fun getEncodedVersionHeader(header: VersionHeader): ByteArray {
val packageBytes = header.packageName.toByteArray(Utf8)
val keyBytes = header.key?.toByteArray(Utf8)
val size = 1 + 2 + packageBytes.size + 2 + (keyBytes?.size ?: 0)
return ByteBuffer.allocate(size).apply {
put(header.version)
putShort(packageBytes.size.toShort())
put(packageBytes)
if (keyBytes == null) {
putShort(0.toShort())
} else {
putShort(keyBytes.size.toShort())
put(keyBytes)
}
}.array()
}
override fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) {
val buffer = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
.putShort(header.segmentLength)
.put(header.nonce)
outputStream.write(buffer.array())
}
}

View file

@ -0,0 +1,28 @@
package com.stevesoltys.seedvault.metadata
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import com.stevesoltys.seedvault.header.VERSION
import java.io.InputStream
data class BackupMetadata(
internal val version: Byte = VERSION,
internal val token: Long,
internal val androidVersion: Int = SDK_INT,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}"
)
internal const val JSON_VERSION = "version"
internal const val JSON_TOKEN = "token"
internal const val JSON_ANDROID_VERSION = "androidVersion"
internal const val JSON_DEVICE_NAME = "deviceName"
internal class DecryptionFailedException(cause: Throwable) : Exception(cause)
class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) {
constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false)
/**
* Indicates that there was an error retrieving the encrypted backup metadata.
*/
constructor(token: Long) : this(token, null, true)
}

View file

@ -0,0 +1,8 @@
package com.stevesoltys.seedvault.metadata
import org.koin.dsl.module
val metadataModule = module {
single<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) }
}

View file

@ -0,0 +1,65 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import javax.crypto.AEADBadTagException
interface MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata
}
internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
override fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata {
val version = inputStream.read().toByte()
if (version < 0) throw IOException()
if (version > VERSION) throw UnsupportedVersionException(version)
val metadataBytes = try {
crypto.decryptMultipleSegments(inputStream)
} catch (e: AEADBadTagException) {
throw DecryptionFailedException(e)
}
return decode(metadataBytes, version, expectedToken)
}
@VisibleForTesting
@Throws(SecurityException::class)
internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
// NOTE: We don't do extensive validation of the parsed input here,
// because it was encrypted with authentication, so we should be able to trust it.
//
// However, it is important to ensure that the expected unauthenticated version and token
// matches the authenticated version and token in the JSON.
try {
val json = JSONObject(bytes.toString(Utf8))
val version = json.getInt(JSON_VERSION).toByte()
if (version != expectedVersion) {
throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.")
}
val token = json.getLong(JSON_TOKEN)
if (token != expectedToken) {
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
return BackupMetadata(
version = version,
token = token,
androidVersion = json.getInt(JSON_ANDROID_VERSION),
deviceName = json.getString(JSON_DEVICE_NAME)
)
} catch (e: JSONException) {
throw SecurityException(e)
}
}
}

View file

@ -0,0 +1,36 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import org.json.JSONObject
import java.io.IOException
import java.io.OutputStream
interface MetadataWriter {
@Throws(IOException::class)
fun write(outputStream: OutputStream, token: Long)
}
internal class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
@Throws(IOException::class)
override fun write(outputStream: OutputStream, token: Long) {
val metadata = BackupMetadata(token = token)
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.encryptMultipleSegments(outputStream, encode(metadata))
}
@VisibleForTesting
internal fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject()
json.put(JSON_VERSION, metadata.version.toInt())
json.put(JSON_TOKEN, metadata.token)
json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
json.put(JSON_DEVICE_NAME, metadata.deviceName)
return json.toString().toByteArray(Utf8)
}
}

View file

@ -0,0 +1,50 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException
import java.io.OutputStream
internal class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin {
override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage)
}
@Throws(IOException::class)
override fun initializeDevice() {
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
// create backup folders
val kvDir = storage.currentKvBackupDir
val fullDir = storage.currentFullBackupDir
// wipe existing data
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
}
@Throws(IOException::class)
override fun getMetadataOutputStream(): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException()
val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile)
}
override val providerPackageName: String? by lazy {
val authority = storage.getAuthority() ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
providerInfo.packageName
}
}

View file

@ -0,0 +1,32 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import java.io.IOException
import java.io.OutputStream
private val TAG = DocumentsProviderFullBackup::class.java.simpleName
internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
@Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName)
?: throw IOException()
return storage.getOutputStream(file)
}
@Throws(IOException::class)
override fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
Log.i(TAG, "Deleting $packageName...")
val file = storage.currentFullBackupDir?.findFile(packageName) ?: return
if (!file.delete()) throw IOException("Failed to delete $packageName")
}
}

View file

@ -0,0 +1,24 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import java.io.IOException
import java.io.InputStream
internal class DocumentsProviderFullRestorePlugin(
private val documentsStorage: DocumentsStorage) : FullRestorePlugin {
@Throws(IOException::class)
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
return backupDir.findFile(packageInfo.packageName) != null
}
@Throws(IOException::class)
override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException()
return documentsStorage.getInputStream(packageFile)
}
}

View file

@ -0,0 +1,53 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException
import java.io.OutputStream
internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin {
private var packageFile: DocumentFile? = null
override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
@Throws(IOException::class)
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName)
?: return false
return packageFile.listFiles().isNotEmpty()
}
@Throws(IOException::class)
override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
// remember package file for subsequent operations
packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName)
}
@Throws(IOException::class)
override fun removeDataOfPackage(packageInfo: PackageInfo) {
// we cannot use the cached this.packageFile here,
// because this can be called before [ensureRecordStorageForPackage]
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
packageFile.delete()
}
@Throws(IOException::class)
override fun deleteRecord(packageInfo: PackageInfo, key: String) {
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.findFile(key) ?: return
keyFile.delete()
}
@Throws(IOException::class)
override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream {
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.createOrGetFile(key)
return storage.getOutputStream(keyFile)
}
}

View file

@ -0,0 +1,40 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import java.io.IOException
import java.io.InputStream
internal class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin {
private var packageDir: DocumentFile? = null
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return try {
val backupDir = storage.getKVBackupDir(token) ?: return false
// remember package file for subsequent operations
packageDir = backupDir.findFile(packageInfo.packageName)
packageDir != null
} catch (e: IOException) {
false
}
}
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo)
return packageDir.listFiles()
.filter { file -> file.name != null }
.map { file -> file.name!! }
}
@Throws(IOException::class)
override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream {
val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo)
val keyFile = packageDir.findFile(key) ?: throw IOException()
return storage.getInputStream(keyFile)
}
}

View file

@ -0,0 +1,12 @@
package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
}

View file

@ -0,0 +1,89 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import java.io.IOException
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
internal class DocumentsProviderRestorePlugin(
private val context: Context,
private val storage: DocumentsStorage) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(storage)
}
@WorkerThread
override fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(context, rootDir)
return backupSets.isNotEmpty()
}
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
try {
val stream = storage.getInputStream(backupSet.metadataFile)
EncryptedBackupMetadata(backupSet.token, stream)
} catch (e: IOException) {
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
EncryptedBackupMetadata(backupSet.token)
}
}
}
@WorkerThread
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results
rootDir.listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error loading backups from storage", e)
return backupSets
}
for (set in files) {
if (!set.isDirectory || set.name == null) {
if (set.name != FILE_NO_MEDIA) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
}
continue
}
val token = try {
set.name!!.toLong()
} catch (e: NumberFormatException) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
continue
}
// block until children of set are available
val metadata = set.findFileBlocking(context, FILE_BACKUP_METADATA)
if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else {
backupSets.add(BackupSet(token, metadata))
}
}
return backupSets
}
}
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -0,0 +1,204 @@
package com.stevesoltys.seedvault.plugins.saf
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.database.ContentObserver
import android.net.Uri
import android.provider.DocumentsContract.*
import android.provider.DocumentsContract.Document.*
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES
const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
const val FILE_BACKUP_METADATA = ".backup.metadata"
const val FILE_NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val settingsManager: SettingsManager) {
private val storage: Storage? = settingsManager.getStorage()
private val token: Long = settingsManager.getBackupToken()
internal val rootBackupDir: DocumentFile? by lazy {
val parent = storage?.getDocumentFile(context) ?: return@lazy null
try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
}
}
private val currentToken: Long by lazy {
if (token != 0L) token
else settingsManager.getAndSaveNewBackupToken().apply {
Log.d(TAG, "Using a fresh backup token: $this")
}
}
private val currentSetDir: DocumentFile? by lazy {
val currentSetName = currentToken.toString()
try {
rootBackupDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
val currentFullBackupDir: DocumentFile? by lazy {
try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
val currentKvBackupDir: DocumentFile? by lazy {
try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
fun getAuthority(): String? = storage?.uri?.authority
fun getSetDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentSetDir
return rootBackupDir?.findFile(token.toString())
}
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
}
fun getFullBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
}
@Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream {
return context.contentResolver.openInputStream(file.uri) ?: throw IOException()
}
@Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream {
return context.contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
}
}
@Throws(IOException::class)
fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile {
return findFile(name) ?: createFile(mimeType, name) ?: throw IOException()
}
@Throws(IOException::class)
fun DocumentFile.createOrGetDirectory(name: String): DocumentFile {
return findFile(name) ?: createDirectory(name) ?: throw IOException()
}
@Throws(IOException::class)
fun DocumentFile.deleteContents() {
for (file in listFiles()) file.delete()
}
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
if (name != packageInfo.packageName) throw AssertionError()
}
/**
* Works like [DocumentFile.listFiles] except that it waits until the DocumentProvider has a result.
* This prevents getting an empty list even though there are children to be listed.
*/
@Throws(IOException::class)
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE)
val result = ArrayList<DocumentFile>()
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null
var cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
var loaded = false
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
loaded = true
}
})
val timeout = MINUTES.toMillis(2)
var time = 0
while (!loaded && time < timeout) {
Thread.sleep(50)
time += 50
}
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
closeQuietly(cursor)
// do a new query after content was loaded
@SuppressLint("Recycle") // gets closed after with block
cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
}
with(cursor) {
while (moveToNext()) {
val documentId = getString(0)
val isDirectory = getString(1) == MIME_TYPE_DIR
val file = if (isDirectory) {
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!!
} else {
val documentUri = buildDocumentUriUsingTree(uri, documentId)
DocumentFile.fromSingleUri(context, documentUri)!!
}
result.add(file)
}
}
return result
}
fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
val files = try {
listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error finding file blocking", e)
return null
}
for (doc in files) {
if (displayName == doc.name) return doc
}
return null
}

View file

@ -0,0 +1,46 @@
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
class RestoreActivity : RequireProvisioningActivity() {
private val viewModel: RestoreViewModel by viewModel()
override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isSetupWizard) hideSystemUI()
setContentView(R.layout.activity_fragment_container)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
if (set != null) showFragment(RestoreProgressFragment())
})
if (savedInstanceState == null) {
showFragment(RestoreSetFragment())
}
}
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!viewModel.validLocationIsSet()) {
showStorageActivity()
} else if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
}
}
}

View file

@ -0,0 +1,73 @@
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.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.isDebugBuild
import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
backupNameView.text = set.device
})
viewModel.restoreProgress.observe(this, Observer { currentPackage ->
val appName = getAppName(requireActivity().packageManager, currentPackage)
val displayName = if (isDebugBuild()) "$appName (${currentPackage})" else appName
currentPackageView.text = getString(R.string.restore_current_package, displayName)
})
viewModel.restoreFinished.observe(this, Observer { finished ->
progressBar.visibility = INVISIBLE
button.visibility = VISIBLE
if (finished == 0) {
// success
currentPackageView.text = getString(R.string.restore_finished_success)
warningView.text = if (settingsManager.getStorage()?.isUsb == true) {
getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable))
} else {
getString(R.string.restore_finished_warning_only_installed, null)
}
warningView.visibility = VISIBLE
} else {
// error
currentPackageView.text = getString(R.string.restore_finished_error)
currentPackageView.setTextColor(warningView.textColors)
}
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
})
button.setOnClickListener {
requireActivity().setResult(RESULT_OK)
requireActivity().finishAfterTransition()
}
}
}

View file

@ -0,0 +1,42 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
internal class RestoreSetAdapter(
private val listener: RestoreSetClickListener,
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_restore_set, parent, false) as View
return RestoreSetViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: RestoreSetViewHolder, position: Int) {
holder.bind(items[position])
}
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
internal fun bind(item: RestoreSet) {
v.setOnClickListener { listener.onRestoreSetClicked(item) }
titleView.text = item.name
subtitleView.text = "Android Backup" // TODO change to backup date when available
}
}
}

View file

@ -0,0 +1,60 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_restore_set.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreSetFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_set, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
backView.setOnClickListener { requireActivity().finishAfterTransition() }
}
override fun onStart() {
super.onStart()
if (viewModel.recoveryCodeIsSet() && viewModel.validLocationIsSet()) {
viewModel.loadRestoreSets()
}
}
private fun onRestoreSetsLoaded(result: RestoreSetResult) {
if (result.hasError()) {
errorView.visibility = VISIBLE
listView.visibility = INVISIBLE
progressBar.visibility = INVISIBLE
errorView.text = result.errorMsg
} else {
errorView.visibility = INVISIBLE
listView.visibility = VISIBLE
progressBar.visibility = INVISIBLE
listView.adapter = RestoreSetAdapter(viewModel, result.sets)
}
}
}
internal interface RestoreSetClickListener {
fun onRestoreSetClicked(set: RestoreSet)
}

View file

@ -0,0 +1,155 @@
package com.stevesoltys.seedvault.restore
import android.app.Application
import android.app.backup.IBackupManager
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
private val TAG = RestoreViewModel::class.java.simpleName
internal class RestoreViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val backupManager: IBackupManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestoreSetClickListener {
override val isRestoreOperation = true
private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null
private val monitor = BackupMonitor()
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
private val mChosenRestoreSet = MutableLiveData<RestoreSet>()
internal val chosenRestoreSet: LiveData<RestoreSet> get() = mChosenRestoreSet
private val mRestoreProgress = MutableLiveData<String>()
internal val restoreProgress: LiveData<String> get() = mRestoreProgress
private val mRestoreFinished = MutableLiveData<Int>()
// Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
internal fun loadRestoreSets() {
val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
this.session = session
if (session == null) {
Log.e(TAG, "beginRestoreSession() returned null session")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
}
val observer = this.observer ?: RestoreObserver()
this.observer = observer
val setResult = session.getAvailableRestoreSets(observer, monitor)
if (setResult != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
}
}
override fun onRestoreSetClicked(set: RestoreSet) {
val session = this.session
check(session != null) { "Restore set clicked, but no session available" }
session.restoreAll(set.token, observer, monitor)
mChosenRestoreSet.value = set
}
override fun onCleared() {
super.onCleared()
endSession()
}
private fun endSession() {
session?.endRestoreSession()
session = null
observer = null
}
@WorkerThread
private inner class RestoreObserver : IRestoreObserver.Stub() {
/**
* Supply a list of the restore datasets available from the current transport.
* This method is invoked as a callback following the application's use of the
* [IRestoreSession.getAvailableRestoreSets] method.
*
* @param restoreSets An array of [RestoreSet] objects
* describing all of the available datasets that are candidates for restoring to
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
if (restoreSets == null || restoreSets.isEmpty()) {
mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
} else {
mRestoreSets.postValue(RestoreSetResult(restoreSets))
}
}
/**
* The restore operation has begun.
*
* @param numPackages The total number of packages being processed in this restore operation.
*/
override fun restoreStarting(numPackages: Int) {
// noop
}
/**
* An indication of which package is being restored currently,
* out of the total number provided in the [restoreStarting] callback.
* This method is not guaranteed to be called.
*
* @param nowBeingRestored The index, between 1 and the numPackages parameter
* to the [restoreStarting] callback, of the package now being restored.
* @param currentPackage The name of the package now being restored.
*/
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
// nowBeingRestored reporting is buggy, so don't use it
mRestoreProgress.postValue(currentPackage)
}
/**
* The restore operation has completed.
*
* @param result Zero on success; a nonzero error code if the restore operation
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
mRestoreFinished.postValue(result)
endSession()
}
}
}
internal class RestoreSetResult(
internal val sets: Array<out RestoreSet>,
internal val errorMsg: String?) {
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
internal fun hasError(): Boolean = errorMsg != null
}

View file

@ -0,0 +1,29 @@
package com.stevesoltys.seedvault.settings
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_about.*
class AboutDialogFragment : DialogFragment() {
companion object {
internal val TAG = AboutDialogFragment::class.java.simpleName
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_about, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val linkMovementMethod = LinkMovementMethod.getInstance()
licenseView.movementMethod = linkMovementMethod
authorView.movementMethod = linkMovementMethod
sponsorView.movementMethod = linkMovementMethod
}
}

View file

@ -0,0 +1,37 @@
package com.stevesoltys.seedvault.settings
import android.content.ContentResolver
import android.provider.Settings
import java.util.concurrent.TimeUnit.DAYS
private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS
private const val DELIMITER = ','
private const val KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS = "key_value_backup_interval_milliseconds"
private const val FULL_BACKUP_INTERVAL_MILLISECONDS = "full_backup_interval_milliseconds"
object BackupManagerSettings {
/**
* This clears the backup settings, so that default values will be used.
*/
fun enableAutomaticBackups(resolver: ContentResolver) {
// setting this to null will cause the BackupManagerConstants to use default values
setSettingValue(resolver, null)
}
/**
* This sets the backup intervals to a longer than default value. Currently 30 days
*/
fun disableAutomaticBackups(resolver: ContentResolver) {
val value = DAYS.toMillis(30)
val kv = "$KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS=$value"
val full = "$FULL_BACKUP_INTERVAL_MILLISECONDS=$value"
setSettingValue(resolver, "$kv$DELIMITER$full")
}
private fun setSettingValue(resolver: ContentResolver, value: String?) {
Settings.Secure.putString(resolver, SETTING, value)
}
}

View file

@ -0,0 +1,44 @@
package com.stevesoltys.seedvault.settings
import android.os.Bundle
import androidx.annotation.CallSuper
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class SettingsActivity : RequireProvisioningActivity() {
private val viewModel: SettingsViewModel by viewModel()
private val notificationManager: BackupNotificationManager by inject()
override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fragment_container)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment())
}
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.validLocationIsSet()) {
showStorageActivity()
// remove potential error notifications
notificationManager.onBackupErrorSeen()
}
}
}

View file

@ -0,0 +1,187 @@
package com.stevesoltys.seedvault.settings
import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.restore.RestoreActivity
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import java.util.*
private val TAG = SettingsFragment::class.java.name
class SettingsFragment : PreferenceFragmentCompat() {
private val viewModel: SettingsViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject()
private val backupManager: IBackupManager by inject()
private lateinit var backup: TwoStatePreference
private lateinit var autoRestore: TwoStatePreference
private lateinit var backupLocation: Preference
private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null
private var storage: Storage? = null
private val usbFilter = IntentFilter(ACTION_USB_DEVICE_ATTACHED).apply {
addAction(ACTION_USB_DEVICE_DETACHED)
}
private val usbReceiver = object : UsbMonitor() {
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
return device.isMassStorage()
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
setMenuItemStates()
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
setHasOptionsMenu(true)
backup = findPreference<TwoStatePreference>("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
backupManager.isBackupEnabled = enabled
return@OnPreferenceChangeListener true
} catch (e: RemoteException) {
e.printStackTrace()
backup.isChecked = !enabled
return@OnPreferenceChangeListener false
}
}
backupLocation = findPreference<Preference>("backup_location")!!
backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation()
true
}
autoRestore = findPreference<TwoStatePreference>("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
backupManager.setAutoRestore(enabled)
return@OnPreferenceChangeListener true
} catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e)
autoRestore.isChecked = !enabled
return@OnPreferenceChangeListener false
}
}
}
override fun onStart() {
super.onStart()
// we need to re-set the title when returning to this fragment
activity?.setTitle(R.string.backup)
storage = settingsManager.getStorage()
setBackupState()
setAutoRestoreState()
setBackupLocationSummary()
setMenuItemStates()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
}
override fun onStop() {
super.onStop()
if (storage?.isUsb == true) context?.unregisterReceiver(usbReceiver)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.settings_menu, menu)
menuBackupNow = menu.findItem(R.id.action_backup)
menuRestore = menu.findItem(R.id.action_restore)
if (resources.getBoolean(R.bool.show_restore_in_settings)) {
menuRestore?.isVisible = true
}
setMenuItemStates()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == R.id.action_backup -> {
viewModel.backupNow()
true
}
item.itemId == R.id.action_restore -> {
startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
item.itemId == R.id.action_about -> {
AboutDialogFragment().show(fragmentManager!!, AboutDialogFragment.TAG)
true
}
else -> super.onOptionsItemSelected(item)
}
private fun setBackupState() {
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
} catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e)
backup.isEnabled = false
}
}
private fun setAutoRestoreState() {
activity?.contentResolver?.let {
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
}
}
private fun setBackupLocationSummary() {
// get name of storage location
val storageName = storage?.name ?: getString(R.string.settings_backup_location_none)
// get time of last backup
val lastBackupInMillis = settingsManager.getBackupTime()
val lastBackup = if (lastBackupInMillis == 0L) {
getString(R.string.settings_backup_last_backup_never)
} else {
getRelativeTimeSpanString(lastBackupInMillis, Date().time, MINUTE_IN_MILLIS, 0)
}
backupLocation.summary = getString(R.string.settings_backup_location_summary, storageName, lastBackup)
}
private fun setMenuItemStates() {
val context = context ?: return
if (menuBackupNow != null && menuRestore != null) {
val storage = this.storage
val enabled = storage != null &&
(!storage.isUsb || storage.getDocumentFile(context).isDirectory)
menuBackupNow?.isEnabled = enabled
menuRestore?.isEnabled = enabled
}
}
}

View file

@ -0,0 +1,134 @@
package com.stevesoltys.seedvault.settings
import android.content.Context
import android.hardware.usb.UsbDevice
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import java.util.*
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName"
private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_TIME = "backupTime"
class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
// FIXME Storage is currently plugin specific and not generic
fun setStorage(storage: Storage) {
prefs.edit()
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
}
fun getStorage(): Storage? {
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
val uri = Uri.parse(uriStr)
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException("no storage name")
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
return Storage(uri, name, isUsb)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()
.remove(PREF_KEY_FLASH_DRIVE_NAME)
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
.apply()
} else {
prefs.edit()
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
.apply()
}
}
fun getFlashDrive(): FlashDrive? {
val name = prefs.getString(PREF_KEY_FLASH_DRIVE_NAME, null) ?: return null
val serialNumber = prefs.getString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, null)
val vendorId = prefs.getInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, -1)
val productId = prefs.getInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, -1)
return FlashDrive(name, serialNumber, vendorId, productId)
}
/**
* Generates and returns a new backup token while saving it as well.
* Subsequent calls to [getBackupToken] will return this new token once saved.
*/
fun getAndSaveNewBackupToken(): Long = Date().time.apply {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
/**
* Returns the current backup token or 0 if none exists.
*/
fun getBackupToken(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
/**
* Sets the last backup time to "now".
*/
fun saveNewBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, Date().time)
.apply()
}
/**
* Sets the last backup time to "never".
*/
fun resetBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, 0L)
.apply()
}
/**
* Returns the last backup time in unix epoch milli seconds.
*/
fun getBackupTime(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TIME, 0L)
}
}
data class Storage(
val uri: Uri,
val name: String,
val isUsb: Boolean) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.")
}
data class FlashDrive(
val name: String,
val serialNumber: String?,
val vendorId: Int,
val productId: Int) {
companion object {
fun from(device: UsbDevice) = FlashDrive(
name = "${device.manufacturerName} ${device.productName}",
serialNumber = device.serialNumber,
vendorId = device.vendorId,
productId = device.productId
)
}
}

View file

@ -0,0 +1,20 @@
package com.stevesoltys.seedvault.settings
import android.app.Application
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
override val isRestoreOperation = false
fun backupNow() {
Thread { requestBackup(app) }.start()
}
}

View file

@ -0,0 +1,162 @@
package com.stevesoltys.seedvault.transport
import android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
import android.app.backup.BackupTransport
import android.app.backup.RestoreDescription
import android.app.backup.RestoreSet
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import org.koin.core.KoinComponent
import org.koin.core.inject
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.seedvault.transport.ConfigurableBackupTransport"
private val TAG = ConfigurableBackupTransport::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport(), KoinComponent {
private val backupCoordinator by inject<BackupCoordinator>()
private val restoreCoordinator by inject<RestoreCoordinator>()
override fun transportDirName(): String {
return TRANSPORT_DIRECTORY_NAME
}
override fun name(): String {
return TRANSPORT_ID
}
override fun getTransportFlags(): Int {
return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
}
override fun dataManagementIntent(): Intent {
return Intent(context, SettingsActivity::class.java)
}
override fun dataManagementLabel(): String {
return "Please file a bug if you see this! 1"
}
override fun currentDestinationString(): String {
return "Please file a bug if you see this! 2"
}
// ------------------------------------------------------------------------------------
// General backup methods
//
override fun initializeDevice(): Int {
return backupCoordinator.initializeDevice()
}
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
}
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
}
override fun clearBackupData(packageInfo: PackageInfo): Int {
return backupCoordinator.clearBackupData(packageInfo)
}
override fun finishBackup(): Int {
return backupCoordinator.finishBackup()
}
// ------------------------------------------------------------------------------------
// Key/value incremental backup support
//
override fun requestBackupTime(): Long {
return backupCoordinator.requestBackupTime()
}
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
}
override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
Log.w(TAG, "Warning: Legacy performBackup() method called.")
return performBackup(targetPackage, fileDescriptor, 0)
}
// ------------------------------------------------------------------------------------
// Full backup
//
override fun requestFullBackupTime(): Long {
return backupCoordinator.requestFullBackupTime()
}
override fun checkFullBackupSize(size: Long): Int {
return backupCoordinator.checkFullBackupSize(size)
}
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performFullBackup(targetPackage, socket, flags)
}
override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
}
override fun sendBackupData(numBytes: Int): Int {
return backupCoordinator.sendBackupData(numBytes)
}
override fun cancelFullBackup() {
backupCoordinator.cancelFullBackup()
}
// ------------------------------------------------------------------------------------
// Restore
//
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
return restoreCoordinator.getAvailableRestoreSets()
}
override fun getCurrentRestoreSet(): Long {
return restoreCoordinator.getCurrentRestoreSet()
}
override fun startRestore(token: Long, packages: Array<PackageInfo>): Int {
return restoreCoordinator.startRestore(token, packages)
}
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
return restoreCoordinator.getNextFullRestoreDataChunk(socket)
}
override fun nextRestorePackage(): RestoreDescription? {
return restoreCoordinator.nextRestorePackage()
}
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {
return restoreCoordinator.getRestoreData(outputFileDescriptor)
}
override fun abortFullRestore(): Int {
return restoreCoordinator.abortFullRestore()
}
override fun finishRestore() {
restoreCoordinator.finishRestore()
}
}

View file

@ -0,0 +1,73 @@
package com.stevesoltys.seedvault.transport
import android.app.Service
import android.app.backup.BackupManager
import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP
import android.app.backup.BackupTransport.FLAG_USER_INITIATED
import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE
import android.content.Intent
import android.os.IBinder
import android.os.RemoteException
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.NotificationBackupObserver
import com.stevesoltys.seedvault.R
import org.koin.core.context.GlobalContext.get
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class ConfigurableBackupTransportService : Service() {
private var transport: ConfigurableBackupTransport? = null
override fun onCreate() {
super.onCreate()
transport = ConfigurableBackupTransport(applicationContext)
Log.d(TAG, "Service created.")
}
override fun onBind(intent: Intent): IBinder {
val transport = this.transport ?: throw IllegalStateException("no transport in onBind()")
return transport.binder.apply {
Log.d(TAG, "Transport bound.")
}
}
override fun onDestroy() {
super.onDestroy()
transport = null
Log.d(TAG, "Service destroyed.")
}
}
@WorkerThread
fun requestBackup(context: Context) {
// show notification
val nm: BackupNotificationManager = get().koin.get()
nm.onBackupUpdate(context.getString(R.string.notification_backup_starting), 0, 1, true)
val observer = NotificationBackupObserver(context, true)
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
val packages = PackageService.eligiblePackages
val result = try {
val backupManager: IBackupManager = get().koin.get()
backupManager.requestBackup(packages, observer, BackupMonitor(), flags)
} catch (e: RemoteException) {
Log.e(TAG, "Error during backup: ", e)
nm.onBackupError()
}
if (result == BackupManager.SUCCESS) {
Log.i(TAG, "Backup succeeded ")
} else {
Log.e(TAG, "Backup failed: $result")
}
}

View file

@ -0,0 +1,60 @@
package com.stevesoltys.seedvault.transport
import android.app.backup.IBackupManager
import android.content.pm.IPackageManager
import android.content.pm.PackageInfo
import android.os.RemoteException
import android.os.ServiceManager.getService
import android.os.UserHandle
import android.util.Log
import com.google.android.collect.Sets.newArraySet
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.*
private val TAG = PackageService::class.java.simpleName
private val IGNORED_PACKAGES = newArraySet(
"com.android.externalstorage",
"com.android.providers.downloads.ui",
"com.android.providers.downloads",
"com.android.providers.media",
"com.android.providers.calendar",
"com.android.providers.contacts",
"com.stevesoltys.seedvault"
)
/**
* @author Steve Soltys
* @author Torsten Grote
*/
internal object PackageService : KoinComponent {
private val backupManager: IBackupManager by inject()
private val packageManager: IPackageManager = IPackageManager.Stub.asInterface(getService("package"))
val eligiblePackages: Array<String>
@Throws(RemoteException::class)
get() {
val packages: List<PackageInfo> = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).list as List<PackageInfo>
val packageList = packages
.map { packageInfo -> packageInfo.packageName }
.filter { packageName -> !IGNORED_PACKAGES.contains(packageName) }
.sorted()
Log.d(TAG, "Got ${packageList.size} packages: $packageList")
// TODO why is this filtering out so much?
val eligibleApps = backupManager.filterAppsEligibleForBackupForUser(UserHandle.myUserId(), packageList.toTypedArray())
Log.d(TAG, "Filtering left ${eligibleApps.size} eligible packages: ${Arrays.toString(eligibleApps)}")
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
val packageArray = eligibleApps.toMutableList()
packageArray.add(MAGIC_PACKAGE_MANAGER)
return packageArray.toTypedArray()
}
}

View file

@ -0,0 +1,214 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataWriter
import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
private val TAG = BackupCoordinator::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
internal class BackupCoordinator(
private val context: Context,
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
private var calledClearBackupData = false
// ------------------------------------------------------------------------------------
// Transport initialization and quota
//
/**
* Initialize the storage for this device, erasing all stored data.
* The transport may send the request immediately, or may buffer it.
* After this is called,
* [finishBackup] will be called to ensure the request is sent and received successfully.
*
* If the transport returns anything other than [TRANSPORT_OK] from this method,
* the OS will halt the current initialize operation and schedule a retry in the near future.
* Even if the transport is in a state
* such that attempting to "initialize" the backend storage is meaningless -
* for example, if there is no current live data-set at all,
* or there is no authenticated account under which to store the data remotely -
* the transport should return [TRANSPORT_OK] here
* and treat the initializeDevice() / finishBackup() pair as a graceful no-op.
*
* @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure).
*/
fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
writeBackupMetadata(settingsManager.getBackupToken())
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR
}
}
fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean {
// We need to exclude the DocumentsProvider used to store backup data.
// Otherwise, it gets killed when we back it up, terminating our backup.
return targetPackage.packageName != plugin.providerPackageName
}
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
Log.i(TAG, "Reported quota of $quota bytes.")
return quota
}
// ------------------------------------------------------------------------------------
// Key/value incremental backup support
//
/**
* Verify that this is a suitable time for a key/value backup pass.
* This should return zero if a backup is reasonable right now, some positive value otherwise.
* This method will be called outside of the [performIncrementalBackup]/[finishBackup] pair.
*
* If this is not a suitable time for a backup, the transport should return a backoff delay,
* in milliseconds, after which the Backup Manager should try again.
*
* @return Zero if this is a suitable time for a backup pass, or a positive time delay
* in milliseconds to suggest deferring the backup pass for a while.
*/
fun requestBackupTime(): Long = getBackupBackoff().apply {
Log.i(TAG, "Request incremental backup time. Returned $this")
}
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
// backups of package manager metadata do not respect backoff
// we need to reject them manually when now is not a good time for a backup
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
}
// ------------------------------------------------------------------------------------
// Full backup
//
/**
* Verify that this is a suitable time for a full-data backup pass.
* This should return zero if a backup is reasonable right now, some positive value otherwise.
* This method will be called outside of the [performFullBackup]/[finishBackup] pair.
*
* If this is not a suitable time for a backup, the transport should return a backoff delay,
* in milliseconds, after which the Backup Manager should try again.
*
* @return Zero if this is a suitable time for a backup pass, or a positive time delay
* in milliseconds to suggest deferring the backup pass for a while.
*
* @see [requestBackupTime]
*/
fun requestFullBackupTime(): Long = getBackupBackoff().apply {
Log.i(TAG, "Request full backup time. Returned $this")
}
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
}
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
fun cancelFullBackup() = full.cancelFullBackup()
// Clear and Finish
/**
* Erase the given application's data from the backup destination.
* This clears out the given package's data from the current backup set,
* making it as though the app had never yet been backed up.
* After this is called, [finishBackup] must be called
* to ensure that the operation is recorded successfully.
*
* @return the same error codes as [performFullBackup].
*/
fun clearBackupData(packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName
Log.i(TAG, "Clear Backup Data of $packageName.")
try {
kv.clearBackupData(packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
return TRANSPORT_ERROR
}
try {
full.clearBackupData(packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR
}
calledClearBackupData = true
return TRANSPORT_OK
}
fun finishBackup(): Int = when {
kv.hasState() -> {
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
kv.finishBackup()
}
full.hasState() -> {
check(!kv.hasState()) { "Full backup has state, but K/V backup has dangling state as well" }
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
calledInitialize = false
calledClearBackupData = false
TRANSPORT_OK
}
else -> throw IllegalStateException("Unexpected state in finishBackup()")
}
@Throws(IOException::class)
private fun writeBackupMetadata(token: Long) {
val outputStream = plugin.getMetadataOutputStream()
metadataWriter.write(outputStream, token)
}
private fun getBackupBackoff(): Long {
val noBackoff = 0L
val defaultBackoff = DAYS.toMillis(30)
// back off if there's no storage set
val storage = settingsManager.getStorage() ?: return defaultBackoff
// don't back off if storage is not ejectable or available right now
return if (!storage.isUsb || storage.getDocumentFile(context).isDirectory) noBackoff
// otherwise back off
else defaultBackoff
}
}

View file

@ -0,0 +1,11 @@
package com.stevesoltys.seedvault.transport.backup
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val backupModule = module {
single { InputFactory() }
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -0,0 +1,34 @@
package com.stevesoltys.seedvault.transport.backup
import java.io.IOException
import java.io.OutputStream
interface BackupPlugin {
val kvBackupPlugin: KVBackupPlugin
val fullBackupPlugin: FullBackupPlugin
/**
* Initialize the storage for this device, erasing all stored data.
*/
@Throws(IOException::class)
fun initializeDevice()
/**
* Returns an [OutputStream] for writing backup metadata.
*/
@Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream
/**
* Returns the package name of the app that provides the backend storage
* which is used for the current backup location.
*
* Plugins are advised to cache this as it will be requested frequently.
*
* @return null if no package name could be found
*/
val providerPackageName: String?
}

View file

@ -0,0 +1,186 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.VersionHeader
import libcore.io.IoUtils.closeQuietly
import org.apache.commons.io.IOUtils
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
private class FullBackupState(
internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream,
internal val outputStream: OutputStream) {
internal val packageName: String = packageInfo.packageName
internal var size: Long = 0
}
const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
private val TAG = FullBackup::class.java.simpleName
internal class FullBackup(
private val plugin: FullBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto) {
private var state: FullBackupState? = null
fun hasState() = state != null
fun getQuota(): Long = plugin.getQuota()
fun checkFullBackupSize(size: Long): Int {
Log.i(TAG, "Check full backup size of $size bytes.")
return when {
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED
else -> TRANSPORT_OK
}
}
/**
* Begin the process of sending a packages' full-data archive to the backend.
* The description of the package whose data will be delivered is provided,
* as well as the socket file descriptor on which the transport will receive the data itself.
*
* If the package is not eligible for backup,
* the transport should return [TRANSPORT_PACKAGE_REJECTED].
* In this case the system will simply proceed with the next candidate if any,
* or finish the full backup operation if all apps have been processed.
*
* After the transport returns [TRANSPORT_OK] from this method,
* the OS will proceed to call [sendBackupData] one or more times
* to deliver the packages' data as a streamed tarball.
* The transport should not read() from the socket except as instructed to
* via the [sendBackupData] method.
*
* After all data has been delivered to the transport, the system will call [finishBackup].
* At this point the transport should commit the data to its datastore, if appropriate,
* and close the socket that had been provided in [performFullBackup].
*
* If the transport returns [TRANSPORT_OK] from this method,
* then the OS will always provide a matching call to [finishBackup]
* even if sending data via [sendBackupData] failed at some point.
*
* @param targetPackage The package whose data is to follow.
* @param socket The socket file descriptor through which the data will be provided.
* If the transport returns [TRANSPORT_PACKAGE_REJECTED] here,
* it must still close this file descriptor now;
* otherwise it should be cached for use during succeeding calls to [sendBackupData],
* and closed in response to [finishBackup].
* @param flags [FLAG_USER_INITIATED] or 0.
* @return [TRANSPORT_PACKAGE_REJECTED] to indicate that the package is not to be backed up;
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
*/
fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int {
if (state != null) throw AssertionError()
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
// get OutputStream to write backup data into
val outputStream = try {
plugin.getOutputStream(targetPackage)
} catch (e: IOException) {
Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e)
return backupError(TRANSPORT_ERROR)
}
// create new state
val inputStream = inputFactory.getInputStream(socket)
state = FullBackupState(targetPackage, socket, inputStream, outputStream)
// store version header
val state = this.state ?: throw AssertionError()
val header = VersionHeader(packageName = state.packageName)
try {
headerWriter.writeVersion(state.outputStream, header)
crypto.encryptHeader(state.outputStream, header)
} catch (e: IOException) {
Log.e(TAG, "Error writing backup header", e)
return backupError(TRANSPORT_ERROR)
}
return TRANSPORT_OK
}
/**
* Method to reset state,
* because [finishBackup] is not called
* when we don't return [TRANSPORT_OK] from [performFullBackup].
*/
private fun backupError(result: Int): Int {
Log.i(TAG, "Resetting state because of full backup error.")
state = null
return result
}
fun sendBackupData(numBytes: Int): Int {
val state = this.state
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
// check if size fits quota
state.size += numBytes
val quota = plugin.getQuota()
if (state.size > quota) {
Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.")
return TRANSPORT_QUOTA_EXCEEDED
}
Log.i(TAG, "Send full backup data of $numBytes bytes.")
return try {
val payload = IOUtils.readFully(state.inputStream, numBytes)
crypto.encryptSegment(state.outputStream, payload)
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
TRANSPORT_ERROR
}
}
fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
fun cancelFullBackup() {
Log.i(TAG, "Cancel full backup")
val state = this.state ?: throw AssertionError("No state when canceling")
clearState()
try {
plugin.removeDataOfPackage(state.packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e)
}
// TODO roll back to the previous known-good archive
}
fun finishBackup(): Int {
Log.i(TAG, "Finish full backup of ${state!!.packageName}.")
return clearState()
}
private fun clearState(): Int {
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
return try {
state.outputStream.flush()
closeQuietly(state.outputStream)
closeQuietly(state.inputStream)
closeQuietly(state.inputFileDescriptor)
TRANSPORT_OK
} catch (e: IOException) {
Log.w(TAG, "Error when clearing state", e)
TRANSPORT_ERROR
} finally {
this.state = null
}
}
}

View file

@ -0,0 +1,21 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.OutputStream
interface FullBackupPlugin {
fun getQuota(): Long
// TODO consider using a salted hash for the package name to not leak it to the storage server
@Throws(IOException::class)
fun getOutputStream(targetPackage: PackageInfo): OutputStream
/**
* Remove all data associated with the given package.
*/
@Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo)
}

View file

@ -0,0 +1,21 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupDataInput
import android.os.ParcelFileDescriptor
import java.io.FileInputStream
import java.io.InputStream
/**
* This class exists for easier testing, so we can mock it and return custom data inputs.
*/
internal class InputFactory {
fun getBackupDataInput(inputFileDescriptor: ParcelFileDescriptor): BackupDataInput {
return BackupDataInput(inputFileDescriptor.fileDescriptor)
}
fun getInputStream(inputFileDescriptor: ParcelFileDescriptor): InputStream {
return FileInputStream(inputFileDescriptor.fileDescriptor)
}
}

View file

@ -0,0 +1,194 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.VersionHeader
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
class KVBackupState(internal val packageName: String)
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName
internal class KVBackup(
private val plugin: KVBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto) {
private var state: KVBackupState? = null
fun hasState() = state != null
fun getQuota(): Long = plugin.getQuota()
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
val isIncremental = flags and FLAG_INCREMENTAL != 0
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName
when {
isIncremental -> {
Log.i(TAG, "Performing incremental K/V backup for $packageName")
}
isNonIncremental -> {
Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
}
else -> {
Log.i(TAG, "Performing K/V backup for $packageName")
}
}
// initialize state
if (this.state != null) throw AssertionError()
this.state = KVBackupState(packageInfo.packageName)
// check if we have existing data for the given package
val hasDataForPackage = try {
plugin.hasDataForPackage(packageInfo)
} catch (e: IOException) {
Log.e(TAG, "Error checking for existing data for ${packageInfo.packageName}.", e)
return backupError(TRANSPORT_ERROR)
}
if (isIncremental && !hasDataForPackage) {
Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.")
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
}
// TODO check if package is over-quota
if (isNonIncremental && hasDataForPackage) {
Log.w(TAG, "Requested non-incremental, deleting existing data.")
try {
clearBackupData(packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
}
}
// ensure there's a place to store K/V for the given package
try {
plugin.ensureRecordStorageForPackage(packageInfo)
} catch (e: IOException) {
Log.e(TAG, "Error ensuring storage for ${packageInfo.packageName}.", e)
return backupError(TRANSPORT_ERROR)
}
// parse and store the K/V updates
return storeRecords(packageInfo, data)
}
private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
// apply the delta operations
for (result in parseBackupStream(data)) {
if (result is Result.Error) {
Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR)
}
val op = (result as Result.Ok).result
try {
if (op.value == null) {
Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
plugin.deleteRecord(packageInfo, op.base64Key)
} else {
val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key)
val header = VersionHeader(packageName = packageInfo.packageName, key = op.key)
headerWriter.writeVersion(outputStream, header)
crypto.encryptHeader(outputStream, header)
crypto.encryptMultipleSegments(outputStream, op.value)
outputStream.flush()
closeQuietly(outputStream)
}
} catch (e: IOException) {
Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
return backupError(TRANSPORT_ERROR)
}
}
return TRANSPORT_OK
}
/**
* Parses a backup stream into individual key/value operations
*/
private fun parseBackupStream(data: ParcelFileDescriptor): Sequence<Result<KVOperation>> {
val changeSet = inputFactory.getBackupDataInput(data)
// Each K/V pair in the restore set is kept in its own file, named by the record key.
// Wind through the data file, extracting individual record operations
// and building a sequence of all the updates to apply in this update.
return generateSequence {
// read the next header or end the sequence in case of error or no more headers
try {
if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence
} catch (e: IOException) {
Log.e(TAG, "Error reading next header", e)
return@generateSequence Result.Error(e)
}
// encode key
val key = changeSet.key
val base64Key = key.encodeBase64()
val dataSize = changeSet.dataSize
// read value
val value = if (dataSize >= 0) {
Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key")
val bytes = ByteArray(dataSize)
val bytesRead = try {
changeSet.readEntityData(bytes, 0, dataSize)
} catch (e: IOException) {
Log.e(TAG, "Error reading entity data for key $key", e)
return@generateSequence Result.Error(e)
}
if (bytesRead != dataSize) {
Log.w(TAG, "Expecting $dataSize bytes, but only read $bytesRead.")
}
bytes
} else null
// add change operation to the sequence
Result.Ok(KVOperation(key, base64Key, value))
}
}
@Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
fun finishBackup(): Int {
Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}")
state = null
return TRANSPORT_OK
}
/**
* Method to reset state,
* because [finishBackup] is not called when we don't return [TRANSPORT_OK].
*/
private fun backupError(result: Int): Int {
Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageName}")
state = null
return result
}
private class KVOperation(
internal val key: String,
internal val base64Key: String,
/**
* value is null when this is a deletion operation
*/
internal val value: ByteArray?
)
private sealed class Result<out T> {
class Ok<out T>(val result: T) : Result<T>()
class Error(val exception: Exception) : Result<Nothing>()
}
}

View file

@ -0,0 +1,49 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.OutputStream
interface KVBackupPlugin {
/**
* Get quota for key/value backups.
*/
fun getQuota(): Long
// TODO consider using a salted hash for the package name (and key) to not leak it to the storage server
/**
* Return true if there are records stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(packageInfo: PackageInfo): Boolean
/**
* This marks the beginning of a backup operation.
*
* Make sure that there is a place to store K/V pairs for the given package.
* E.g. file-based plugins should a create a directory for the package, if none exists.
*/
@Throws(IOException::class)
fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
/**
* Return an [OutputStream] for the given package and key
* which will receive the record's encrypted value.
*/
@Throws(IOException::class)
fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
/**
* Delete the record for the given package identified by the given key.
*/
@Throws(IOException::class)
fun deleteRecord(packageInfo: PackageInfo, key: String)
/**
* Remove all data associated with the given package.
*/
@Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo)
}

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