Merge pull request #47 from stevesoltys/develop
Merge develop into master
This commit is contained in:
commit
f8846ffe45
203 changed files with 10996 additions and 3190 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -47,3 +47,6 @@ gradle-app.setting
|
|||
|
||||
## Android
|
||||
gen/
|
||||
|
||||
## Prebuilt
|
||||
Backup.apk
|
||||
|
|
49
.travis.yml
49
.travis.yml
|
@ -1,17 +1,42 @@
|
|||
dist: trusty
|
||||
|
||||
jdk:
|
||||
- openjdk11
|
||||
|
||||
language: android
|
||||
android:
|
||||
components:
|
||||
- build-tools-28.0.3
|
||||
- android-28
|
||||
- build-tools-29.0.2
|
||||
- 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:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: BJ+0riccbDPQMcvZkYveHHcSa3wlVRdvLIHMJtZQXmjJnYu4Mh2YH4RkZdd+4OBPf2iBCyP1CIxB9NTKldb8Qn1m/6+LcReYf2xd8Y6XCrHDsycT5GZTENEif0EyVPdB1En4NwRVYiNwGMSv49Cz03aGtzq5jrGWxPhYAEY4jt86HKRqw8SCUPEqug3Rz+deG4juUdIAvARiN8jKoqu9EeMOP5ST7nbZjZQbee8SGP7wPW+J7E6kWPvn+mSoZsMXw/ELz8nEAu4pHh/98agreMvApjImpiEpVXNhMpENfk42U+wztiGNspoOh/vDFrNikWFGIJ3lE4yPJteBo2vpVo/7/tfBzKjMnL7c/5ZNMnjv9e2yoqwfpwmh8GzjKaDuwG1Fy8g5ctJAS4wYHr4z4LDlfdmFVUE3r3NPI8XdzsnjVpqkXhC/5eBPO50p82c0Za24SwkmO+JzIaIF41fTt0An9Dd/1Q5321WGJK6HqQwdjRG3HciLF6lNJu/gzSVHnfC9REQGY7vDdNSVaP9ps0W07URewsKwC5Vm5SFYUEFIM2d3C+62+eciqlpfqON6htd9zAZnFTSE6rMTJdGXMs+hLb89C1J3tavz89T2d9Dqnvs6MlKEO3ontDcwYdbx8czPKv22Fm4iI4XG6VTzK9hS4BNCvhvyvqSq7mYIXsA=
|
||||
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:
|
||||
repo: stevesoltys/backup
|
||||
tags: true
|
||||
provider: script
|
||||
script: ./deploy-prebuilt.sh
|
||||
skip_cleanup: true
|
||||
on:
|
||||
repo: stevesoltys/seedvault
|
||||
all_branches: true
|
||||
condition: $TRAVIS_BRANCH =~ ^(master|develop)$
|
||||
|
|
27
Android.mk
Normal file
27
Android.mk
Normal 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)
|
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -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
|
||||
### Fixed
|
||||
- Transport encryption. Some of the application data was not included during encryption.
|
||||
|
@ -11,7 +25,7 @@
|
|||
|
||||
## [0.1.2] - 2019-02-11
|
||||
### 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
|
||||
### Added
|
||||
|
@ -20,4 +34,4 @@
|
|||
- Upgrade target SDK version to 28.
|
||||
|
||||
### 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
215
LICENSE
|
@ -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
|
||||
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:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"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.
|
28
README.md
28
README.md
|
@ -1,28 +1,30 @@
|
|||
# Backup
|
||||
[![Build Status](https://travis-ci.com/stevesoltys/backup.svg?branch=master)](https://travis-ci.com/stevesoltys/backup)
|
||||
# Seedvault
|
||||
[![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/).
|
||||
|
||||
## Features
|
||||
- Backup application data to a zip file.
|
||||
- Restore application data from a zip file.
|
||||
- Password-based encryption.
|
||||
- Backup application data to a flash drive.
|
||||
- Restore application data from a flash drive.
|
||||
- User-friendly encryption using a mnemonic phrase (BIP39).
|
||||
- Automatic daily backups that run in the background.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
internal APIs as `adb backup` and only requires the permission `android.permission.BACKUP` for this.
|
||||
|
||||
## Contributing
|
||||
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/backup.
|
||||
internal APIs as `adb backup` and requires a minimal number of permissions to achieve this.
|
||||
|
||||
## 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
|
||||
* `android.permission.RECEIVE_BOOT_COMPLETED` to schedule automatic backups after boot
|
||||
## Contributing
|
||||
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.
|
||||
|
||||
## 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).
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import groovy.xml.XmlUtil
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
|
||||
compileSdkVersion 28
|
||||
buildToolsVersion '28.0.3'
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion '29.0.2'
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
minSdkVersion 29
|
||||
targetSdkVersion 29
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
@ -25,6 +28,23 @@ android {
|
|||
targetCompatibility 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
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
|
@ -41,11 +61,15 @@ android {
|
|||
}
|
||||
}
|
||||
buildTypes.release.signingConfig = signingConfigs.release
|
||||
buildTypes.debug.signingConfig = signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
@ -60,6 +84,7 @@ preBuild.doLast {
|
|||
parsedXml.component[1].remove(jdkNode)
|
||||
|
||||
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'])
|
||||
XmlUtil.serialize(parsedXml, new FileOutputStream(imlFile))
|
||||
|
||||
|
@ -68,15 +93,37 @@ preBuild.doLast {
|
|||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// To produce these binaries, in latest AOSP source tree, run
|
||||
// $ make
|
||||
compileOnly fileTree(include: [
|
||||
def aospDeps = fileTree(include: [
|
||||
// out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
|
||||
'android.jar',
|
||||
// out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
|
||||
'libcore.jar'
|
||||
], 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.
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
10
app/src/debug/res/values/config.xml
Normal file
10
app/src/debug/res/values/config.xml
Normal 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>
|
|
@ -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)
|
|
@ -1,59 +1,86 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.stevesoltys.backup"
|
||||
android:versionCode="5"
|
||||
android:versionName="0.3.0">
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="26"
|
||||
android:targetSdkVersion="28"
|
||||
tools:ignore="GradleOverrides,OldTargetApi" />
|
||||
package="com.stevesoltys.seedvault"
|
||||
android:versionCode="6"
|
||||
android:versionName="1.0.0-alpha1">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BACKUP"
|
||||
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
|
||||
android:name=".Backup"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
|
||||
<activity
|
||||
android:name="com.stevesoltys.backup.activity.MainActivity"
|
||||
android:label="@string/app_name">
|
||||
android:name=".settings.SettingsActivity"
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name="com.stevesoltys.backup.transport.ConfigurableBackupTransportService"
|
||||
android:name=".transport.ConfigurableBackupTransportService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.backup.TRANSPORT_HOST" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.backup.BackupJobService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
<receiver
|
||||
android:name=".UsbIntentReceiver"
|
||||
android:exported="true">
|
||||
<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>
|
||||
</manifest>
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, "?"));
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package com.stevesoltys.backup.session.backup;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public enum BackupResult {
|
||||
SUCCESS, FAILURE, CANCELLED
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package com.stevesoltys.backup.session.restore;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public enum RestoreResult {
|
||||
SUCCESS, CANCELLED, FAILURE
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
66
app/src/main/java/com/stevesoltys/seedvault/App.kt
Normal file
66
app/src/main/java/com/stevesoltys/seedvault/App.kt
Normal 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"
|
20
app/src/main/java/com/stevesoltys/seedvault/BackupMonitor.kt
Normal file
20
app/src/main/java/com/stevesoltys/seedvault/BackupMonitor.kt
Normal 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, "?"))
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
18
app/src/main/java/com/stevesoltys/seedvault/Base64Utils.kt
Normal file
18
app/src/main/java/com/stevesoltys/seedvault/Base64Utils.kt
Normal 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))
|
||||
}
|
|
@ -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)
|
||||
}
|
108
app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
Normal file
108
app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
Normal 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()}")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
185
app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt
Normal file
185
app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
48
app/src/main/java/com/stevesoltys/seedvault/header/Header.kt
Normal file
48
app/src/main/java/com/stevesoltys/seedvault/header/Header.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.stevesoltys.seedvault.header
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val headerModule = module {
|
||||
single<HeaderWriter> { HeaderWriterImpl() }
|
||||
single<HeaderReader> { HeaderReaderImpl() }
|
||||
}
|
|
@ -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()
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -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?
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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>()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue