Merge pull request #35 from grote/transport

Huge refactoring of backup transport
This commit is contained in:
Steve Soltys 2019-09-09 20:13:59 -04:00 committed by GitHub
commit 7a7059cda3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 4803 additions and 1618 deletions

View file

@ -1,3 +1,4 @@
dist: trusty
language: android
android:
components:
@ -19,6 +20,8 @@ before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
script: ./gradlew check
cache:
directories:
- $HOME/.gradle/caches/

View file

@ -1,5 +1,13 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC

View file

@ -12,6 +12,7 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 28
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@ -27,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")
@ -43,6 +61,7 @@ android {
}
}
buildTypes.release.signingConfig = signingConfigs.release
buildTypes.debug.signingConfig = signingConfigs.release
}
}
@ -70,16 +89,18 @@ 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')
dependencies {
compileOnly aospDeps
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'commons-io:commons-io:2.6'
@ -90,4 +111,14 @@ dependencies {
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.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.0'
testImplementation 'io.mockk:mockk:1.9.3'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
}

Binary file not shown.

View file

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

View file

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

View file

@ -14,7 +14,9 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- This is needed to retrieve the serial number of the device,
so we can store the backups for each device in a unique location -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:name=".Backup"
@ -46,17 +48,12 @@
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" />
</application>
</manifest>

View file

@ -1,11 +1,23 @@
package com.stevesoltys.backup
import android.Manifest.permission.READ_PHONE_STATE
import android.app.Application
import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Build
import android.os.ServiceManager.getService
import android.util.Log
import com.stevesoltys.backup.crypto.KeyManager
import com.stevesoltys.backup.crypto.KeyManagerImpl
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.settings.setDeviceName
import io.github.novacrypto.hashing.Sha256.sha256Twice
const val JOB_ID_BACKGROUND_BACKUP = 1
private const val URI_AUTHORITY_EXTERNAL_STORAGE = "com.android.externalstorage.documents"
private val TAG = Backup::class.java.simpleName
/**
* @author Steve Soltys
@ -17,6 +29,36 @@ class Backup : Application() {
val backupManager: IBackupManager by lazy {
IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE))
}
val keyManager: KeyManager by lazy {
KeyManagerImpl()
}
}
val notificationManager by lazy {
BackupNotificationManager(this)
}
override fun onCreate() {
super.onCreate()
storeDeviceName()
}
private fun storeDeviceName() {
if (getDeviceName(this) != null) return // we already have a stored device name
val permission = READ_PHONE_STATE
if (checkSelfPermission(permission) != PERMISSION_GRANTED) {
throw AssertionError("You need to grant the $permission permission.")
}
// TODO consider just using a hash for the entire device name and store metadata in an encrypted file
val id = sha256Twice(Build.getSerial().toByteArray(Utf8))
.copyOfRange(0, 8)
.encodeBase64()
val name = "${Build.MANUFACTURER} ${Build.MODEL} ($id)"
Log.i(TAG, "Initialized device name to: $name")
setDeviceName(this, name)
}
}
fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE

View file

@ -0,0 +1,93 @@
package com.stevesoltys.backup
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.backup.settings.SettingsActivity
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)
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)
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))
addAction(action)
setOnlyAlertOnce(true)
setAutoCancel(true)
}.build()
nm.notify(NOTIFICATION_ID_ERROR, notification)
}
fun onBackupErrorSeen() {
nm.cancel(NOTIFICATION_ID_ERROR)
}
}

View file

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

View file

@ -1,41 +1,18 @@
package com.stevesoltys.backup
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_MIN
import android.app.backup.BackupManager
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Context
import android.util.Log
import android.util.Log.INFO
import android.util.Log.isLoggable
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
import androidx.core.app.NotificationCompat.PRIORITY_LOW
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport
private const val CHANNEL_ID = "NotificationBackupObserver"
private const val NOTIFICATION_ID = 1
private val TAG = NotificationBackupObserver::class.java.simpleName
private val TAG = NotificationBackupObserver::class.java.name
class NotificationBackupObserver(
private val context: Context,
private val userInitiated: Boolean) : IBackupObserver.Stub() {
class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub() {
private val pm = context.packageManager
private val nm = context.getSystemService(NotificationManager::class.java).apply {
val title = context.getString(R.string.notification_channel_title)
val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_MIN).apply {
enableVibration(false)
}
createNotificationChannel(channel)
}
private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).apply {
setSmallIcon(R.drawable.ic_cloud_upload)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}
private val nm = (context.applicationContext as Backup).notificationManager
/**
* This method could be called several times for packages with full data backup.
@ -45,17 +22,13 @@ class NotificationBackupObserver(
* @param backupProgress Current progress of backup for the package.
*/
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
val transferred = backupProgress.bytesTransferred
val expected = backupProgress.bytesExpected
val transferred = backupProgress.bytesTransferred.toInt()
val expected = backupProgress.bytesExpected.toInt()
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
}
val notification = notificationBuilder.apply {
setContentTitle(context.getString(R.string.notification_title))
setContentText(getAppName(currentBackupPackage))
setProgress(expected.toInt(), transferred.toInt(), false)
}.build()
nm.notify(NOTIFICATION_ID, notification)
val app = getAppName(currentBackupPackage)
nm.onBackupUpdate(app, transferred, expected, userInitiated)
}
/**
@ -72,15 +45,7 @@ class NotificationBackupObserver(
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Completed. Target: $target, status: $status")
}
val title = context.getString(
if (status == 0) R.string.notification_backup_result_complete
else R.string.notification_backup_result_error
)
val notification = notificationBuilder.apply {
setContentTitle(title)
setContentText(getAppName(target))
}.build()
nm.notify(NOTIFICATION_ID, notification)
nm.onBackupResult(getAppName(target), status, userInitiated)
}
/**
@ -94,11 +59,11 @@ class NotificationBackupObserver(
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Backup finished. Status: $status")
}
if (status == BackupManager.SUCCESS) getBackupTransport(context).backupFinished()
nm.cancel(NOTIFICATION_ID)
nm.onBackupFinished()
}
private fun getAppName(packageId: String): CharSequence {
if (packageId == "@pm@") return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
}

View file

@ -11,7 +11,6 @@ import com.stevesoltys.backup.R;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static com.stevesoltys.backup.settings.SettingsManagerKt.areBackupsScheduled;
public class MainActivity extends Activity implements View.OnClickListener {
@ -37,7 +36,6 @@ public class MainActivity extends Activity implements View.OnClickListener {
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);

View file

@ -1,35 +1,26 @@
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.BackupKt.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.SettingsManagerKt.getBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupsScheduled;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.DAYS;
/**
* @author Steve Soltys
@ -89,24 +80,8 @@ public class MainActivityController {
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();
Toast.makeText(parent, "REMOVED", Toast.LENGTH_SHORT).show();
return true;
}

View file

@ -22,7 +22,6 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupPassword;
/**
* @author Steve Soltys
@ -116,7 +115,6 @@ class CreateBackupActivityController {
String password = passwordTextView.getText().toString();
if (originalPassword.equals(password)) {
setBackupPassword(parent, password);
backupService.backupPackageData(selectedPackages, parent);
} else {

View file

@ -29,7 +29,8 @@ import java.util.zip.ZipInputStream;
import libcore.io.IoUtils;
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
import static com.stevesoltys.backup.transport.backup.plugins.DocumentsStorageKt.DIRECTORY_FULL_BACKUP;
/**
* @author Steve Soltys
@ -84,7 +85,7 @@ class RestoreBackupActivityController {
while ((zipEntry = inputStream.getNextEntry()) != null) {
String zipEntryPath = zipEntry.getName();
if (zipEntryPath.startsWith(DEFAULT_FULL_BACKUP_DIRECTORY)) {
if (zipEntryPath.startsWith(DIRECTORY_FULL_BACKUP)) {
String fileName = new File(zipEntryPath).getName();
results.add(fileName);
}

View file

@ -0,0 +1,31 @@
package com.stevesoltys.backup.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"
private const val GCM_AUTHENTICATION_TAG_LENGTH = 128
interface CipherFactory {
fun createEncryptionCipher(): Cipher
fun createDecryptionCipher(iv: ByteArray): Cipher
}
class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory {
override fun createEncryptionCipher(): Cipher {
return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
init(ENCRYPT_MODE, keyManager.getBackupKey())
}
}
override fun createDecryptionCipher(iv: ByteArray): Cipher {
return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
val spec = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv)
init(DECRYPT_MODE, keyManager.getBackupKey(), spec)
}
}
}

View file

@ -0,0 +1,138 @@
package com.stevesoltys.backup.crypto
import com.stevesoltys.backup.header.*
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
/**
* 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)
/**
* 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
}
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)
val encrypted = cipher.doFinal(cleartext)
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(EOFException::class, IOException::class, SecurityException::class)
fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray {
val segmentHeader = headerReader.readSegmentHeader(inputStream)
if (segmentHeader.segmentLength > maxSegmentLength) {
throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength")
}
val buffer = ByteArray(segmentHeader.segmentLength.toInt())
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) throw EOFException()
if (bytesRead != buffer.size) throw IOException()
val cipher = cipherFactory.createDecryptionCipher(segmentHeader.nonce)
return cipher.doFinal(buffer)
}
}

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup.security
package com.stevesoltys.backup.crypto
import android.os.Build.VERSION.SDK_INT
import android.security.keystore.KeyProperties.*
@ -8,11 +8,34 @@ import java.security.KeyStore.SecretKeyEntry
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
private const val KEY_SIZE = 256
internal const val KEY_SIZE = 256
private const val KEY_SIZE_BYTES = KEY_SIZE / 8
private const val KEY_ALIAS = "com.stevesoltys.backup"
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
object KeyManager {
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
}
class KeyManagerImpl : KeyManager {
private val keyStore by lazy {
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
@ -20,18 +43,18 @@ object KeyManager {
}
}
fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException()
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 / 8, "AES")
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
val ksEntry = SecretKeyEntry(secretKeySpec)
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
}
fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
fun getBackupKey(): SecretKey {
override fun getBackupKey(): SecretKey {
val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
return ksEntry.secretKey
}
@ -41,6 +64,7 @@ object KeyManager {
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
// unlocking is required only for decryption, so when restoring from backup
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
return builder.build()
}

View file

@ -0,0 +1,39 @@
package com.stevesoltys.backup.header
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)
key?.let { check(key.length <= 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 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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
package com.stevesoltys.backup.service
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.backup.Backup
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.backup"
)
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class PackageService {
private val backupManager = Backup.backupManager
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.filterAppsEligibleForBackup(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("@pm@")
return packageArray.toTypedArray()
}
}

View file

@ -12,8 +12,6 @@ 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
*/
@ -61,9 +59,6 @@ class BackupObserver implements BackupSessionObserver {
@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();

View file

@ -12,12 +12,9 @@ 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
*/
@ -28,8 +25,6 @@ public class RestoreService {
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());

View file

@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.*
import android.os.Bundle
import android.provider.DocumentsContract.EXTRA_PROMPT
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.lifecycle.ViewModelProviders
@ -38,6 +39,7 @@ class BackupLocationFragment : PreferenceFragmentCompat() {
private fun showChooseFolderActivity() {
val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE)
openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker))
openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
try {

View file

@ -1,11 +1,10 @@
package com.stevesoltys.backup.settings
import android.app.Application
import android.util.ByteStringUtils.toHexString
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEvent
import com.stevesoltys.backup.MutableLiveEvent
import com.stevesoltys.backup.security.KeyManager
import io.github.novacrypto.bip39.*
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
@ -21,7 +20,6 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
internal val wordList: List<CharSequence> by lazy {
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
// TODO factor out entropy generation
val entropy = ByteArray(Words.TWELVE.byteLength())
SecureRandom().nextBytes(entropy)
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
@ -48,10 +46,7 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
}
val mnemonic = input.joinToString(" ")
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
KeyManager.storeBackupKey(seed)
// TODO remove once encryption/decryption uses key from KeyStore
setBackupPassword(getApplication(), toHexString(seed))
Backup.keyManager.storeBackupKey(seed)
mRecoveryCodeSaved.setEvent(true)
}

View file

@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEventHandler
import com.stevesoltys.backup.R
@ -25,8 +26,8 @@ class SettingsActivity : AppCompatActivity() {
setContentView(R.layout.activity_settings)
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore ->
if (wasEmptyBefore) showFragment(SettingsFragment())
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
if (initialSetUp) showFragment(SettingsFragment())
else supportFragmentManager.popBackStack()
})
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
@ -54,8 +55,10 @@ class SettingsActivity : AppCompatActivity() {
// check that backup is provisioned
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.locationIsSet()) {
} else if (!viewModel.validLocationIsSet()) {
showFragment(BackupLocationFragment())
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
}

View file

@ -10,6 +10,7 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.lifecycle.ViewModelProviders
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
@ -99,7 +100,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
item.itemId == R.id.action_restore -> {
Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show()
true
}
else -> super.onOptionsItemSelected(item)

View file

@ -5,8 +5,8 @@ import android.net.Uri
import android.preference.PreferenceManager.getDefaultSharedPreferences
private const val PREF_KEY_BACKUP_URI = "backupUri"
private const val PREF_KEY_DEVICE_NAME = "deviceName"
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
private const val PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled"
fun setBackupFolderUri(context: Context, uri: Uri) {
getDefaultSharedPreferences(context)
@ -21,30 +21,18 @@ fun getBackupFolderUri(context: Context): Uri? {
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.
*/
@Deprecated("Replaced by KeyManager#storeBackupKey()")
fun setBackupPassword(context: Context, password: String) {
fun setDeviceName(context: Context, name: String) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_BACKUP_PASSWORD, password)
.putString(PREF_KEY_DEVICE_NAME, name)
.apply()
}
fun getDeviceName(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null)
}
@Deprecated("Replaced by KeyManager#getBackupKey()")
fun getBackupPassword(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
}
fun setBackupsScheduled(context: Context) {
getDefaultSharedPreferences(context)
.edit()
.putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true)
.apply()
}
fun areBackupsScheduled(context: Context): Boolean {
return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false)
}

View file

@ -4,13 +4,17 @@ import android.app.Application
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEvent
import com.stevesoltys.backup.MutableLiveEvent
import com.stevesoltys.backup.security.KeyManager
import com.stevesoltys.backup.service.backup.requestFullBackup
import com.stevesoltys.backup.isOnExternalStorage
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.transport.requestBackup
private val TAG = SettingsViewModel::class.java.name
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
@ -27,8 +31,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
fun recoveryCodeIsSet() = KeyManager.hasBackupKey()
fun locationIsSet() = getBackupFolderUri(getApplication()) != null
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
fun validLocationIsSet(): Boolean {
val uri = getBackupFolderUri(app) ?: return false
if (uri.isOnExternalStorage()) return true // might be a temporary failure
val file = DocumentFile.fromTreeUri(app, uri) ?: return false
return file.isDirectory
}
fun handleChooseFolderResult(result: Intent?) {
val folderUri = result?.data ?: return
@ -38,15 +48,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
// check if this is initial set-up or a later change
val wasEmptyBefore = getBackupFolderUri(app) == null
val initialSetUp = !validLocationIsSet()
// store backup folder location in settings
setBackupFolderUri(app, folderUri)
// notify the UI that the location has been set
locationWasSet.setEvent(wasEmptyBefore)
locationWasSet.setEvent(initialSetUp)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location chosen: $folderUri")
}
fun backupNow() = Thread { requestFullBackup(app) }.start()
fun backupNow() = Thread { requestBackup(app) }.start()
}

View file

@ -1,199 +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.Intent;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import com.stevesoltys.backup.settings.SettingsActivity;
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 Context context;
private final BackupComponent backupComponent;
private final RestoreComponent restoreComponent;
ConfigurableBackupTransport(Context context) {
this.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 Intent dataManagementIntent() {
return new Intent(context, SettingsActivity.class);
}
@Override
public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) {
return true;
}
@Override
public long requestBackupTime() {
return backupComponent.requestBackupTime();
}
@Override
public String dataManagementLabel() {
return backupComponent.dataManagementLabel();
}
@Override
public int initializeDevice() {
return backupComponent.initializeDevice();
}
@Override
public String currentDestinationString() {
return backupComponent.currentDestinationString();
}
/* Methods related to Backup */
@Override
public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor inFd, int flags) {
return backupComponent.performIncrementalBackup(packageInfo, inFd, flags);
}
@Override
public int performBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
Log.w(TAG, "Warning: Legacy performBackup() method called.");
return performBackup(targetPackage, fileDescriptor, 0);
}
@Override
public int checkFullBackupSize(long size) {
return backupComponent.checkFullBackupSize(size);
}
@Override
public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket, int flags) {
// TODO handle flags
return performFullBackup(targetPackage, socket);
}
@Override
public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
return backupComponent.performFullBackup(targetPackage, fileDescriptor);
}
@Override
public int sendBackupData(int numBytes) {
return backupComponent.sendBackupData(numBytes);
}
@Override
public void cancelFullBackup() {
backupComponent.cancelFullBackup();
}
@Override
public int finishBackup() {
return backupComponent.finishBackup();
}
@Override
public long requestFullBackupTime() {
return backupComponent.requestFullBackupTime();
}
@Override
public long getBackupQuota(String packageName, boolean isFullBackup) {
return backupComponent.getBackupQuota(packageName, isFullBackup);
}
@Override
public int clearBackupData(PackageInfo packageInfo) {
return backupComponent.clearBackupData(packageInfo);
}
public void backupFinished() {
backupComponent.backupFinished();
}
/* Methods related to Restore */
@Override
public long getCurrentRestoreSet() {
return restoreComponent.getCurrentRestoreSet();
}
@Override
public int startRestore(long token, PackageInfo[] packages) {
return restoreComponent.startRestore(token, packages);
}
@Override
public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) {
return restoreComponent.getNextFullRestoreDataChunk(socket);
}
@Override
public RestoreSet[] getAvailableRestoreSets() {
return restoreComponent.getAvailableRestoreSets();
}
@Override
public RestoreDescription nextRestorePackage() {
return restoreComponent.nextRestorePackage();
}
@Override
public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
return restoreComponent.getRestoreData(outputFileDescriptor);
}
@Override
public int abortFullRestore() {
return restoreComponent.abortFullRestore();
}
@Override
public void finishRestore() {
restoreComponent.finishRestore();
}
}

View file

@ -0,0 +1,161 @@
package com.stevesoltys.backup.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.Build.VERSION.SDK_INT
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.settings.SettingsActivity
const val DEFAULT_RESTORE_SET_TOKEN: Long = 1
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport"
private val TAG = ConfigurableBackupTransport::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport() {
private val pluginManager = PluginManager(context)
private val backupCoordinator = pluginManager.backupCoordinator
private val restoreCoordinator = pluginManager.restoreCoordinator
override fun transportDirName(): String {
return TRANSPORT_DIRECTORY_NAME
}
override fun name(): String {
// TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
return this.javaClass.name
}
override fun getTransportFlags(): Int {
return if (SDK_INT >= 28) FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED else 0
}
override fun dataManagementIntent(): Intent {
return Intent(context, SettingsActivity::class.java)
}
override fun dataManagementLabel(): String {
return "Please file a bug if you see this! 1"
}
override fun currentDestinationString(): String {
return "Please file a bug if you see this! 2"
}
// ------------------------------------------------------------------------------------
// General backup methods
//
override fun initializeDevice(): Int {
return backupCoordinator.initializeDevice()
}
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
}
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
}
override fun clearBackupData(packageInfo: PackageInfo): Int {
return backupCoordinator.clearBackupData(packageInfo)
}
override fun finishBackup(): Int {
return backupCoordinator.finishBackup()
}
// ------------------------------------------------------------------------------------
// Key/value incremental backup support
//
override fun requestBackupTime(): Long {
return backupCoordinator.requestBackupTime()
}
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
}
override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
Log.w(TAG, "Warning: Legacy performBackup() method called.")
return performBackup(targetPackage, fileDescriptor, 0)
}
// ------------------------------------------------------------------------------------
// Full backup
//
override fun requestFullBackupTime(): Long {
return backupCoordinator.requestFullBackupTime()
}
override fun checkFullBackupSize(size: Long): Int {
return backupCoordinator.checkFullBackupSize(size)
}
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performFullBackup(targetPackage, socket, flags)
}
override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
}
override fun sendBackupData(numBytes: Int): Int {
return backupCoordinator.sendBackupData(numBytes)
}
override fun cancelFullBackup() {
backupCoordinator.cancelFullBackup()
}
// ------------------------------------------------------------------------------------
// Restore
//
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
return restoreCoordinator.getAvailableRestoreSets()
}
override fun getCurrentRestoreSet(): Long {
return restoreCoordinator.getCurrentRestoreSet()
}
override fun startRestore(token: Long, packages: Array<PackageInfo>): Int {
return restoreCoordinator.startRestore(token, packages)
}
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
return restoreCoordinator.getNextFullRestoreDataChunk(socket)
}
override fun nextRestorePackage(): RestoreDescription? {
return restoreCoordinator.nextRestorePackage()
}
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {
return restoreCoordinator.getRestoreData(outputFileDescriptor)
}
override fun abortFullRestore(): Int {
return restoreCoordinator.abortFullRestore()
}
override fun finishRestore() {
restoreCoordinator.finishRestore()
}
}

View file

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

View file

@ -1,13 +1,14 @@
package com.stevesoltys.backup.service.backup
package com.stevesoltys.backup.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.job.JobParameters
import android.app.job.JobService
import android.app.backup.BackupTransport
import android.content.Context
import android.content.Context.BACKUP_SERVICE
import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP
import android.app.backup.BackupTransport.FLAG_USER_INITIATED
import android.content.Intent
import android.os.IBinder
import android.os.RemoteException
import android.util.Log
import androidx.annotation.WorkerThread
@ -15,36 +16,40 @@ import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.NotificationBackupObserver
import com.stevesoltys.backup.service.PackageService
import com.stevesoltys.backup.session.backup.BackupMonitor
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
private val TAG = BackupJobService::class.java.name
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
// TODO might not be needed, if the OS really schedules backups on its own
class BackupJobService : JobService() {
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class ConfigurableBackupTransportService : Service() {
override fun onStartJob(params: JobParameters): Boolean {
Log.i(TAG, "Triggering full backup")
try {
requestFullBackup(this)
} finally {
jobFinished(params, false)
}
return true
private var transport: ConfigurableBackupTransport? = null
override fun onCreate() {
super.onCreate()
transport = ConfigurableBackupTransport(applicationContext)
Log.d(TAG, "Service created.")
}
override fun onStopJob(params: JobParameters): Boolean {
try {
Backup.backupManager.cancelBackups()
} catch (e: RemoteException) {
Log.e(TAG, "Error cancelling backup: ", e)
override fun onBind(intent: Intent): IBinder {
val transport = this.transport ?: throw IllegalStateException()
return transport.binder.apply {
Log.d(TAG, "Transport bound.")
}
return true
}
override fun onDestroy() {
super.onDestroy()
transport = null
Log.d(TAG, "Service destroyed.")
}
}
@WorkerThread
fun requestFullBackup(context: Context) {
fun requestBackup(context: Context) {
context.startService(Intent(context, ConfigurableBackupTransportService::class.java))
val observer = NotificationBackupObserver(context, true)
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED

View file

@ -0,0 +1,51 @@
package com.stevesoltys.backup.transport
import android.content.Context
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.transport.backup.BackupCoordinator
import com.stevesoltys.backup.transport.backup.FullBackup
import com.stevesoltys.backup.transport.backup.InputFactory
import com.stevesoltys.backup.transport.backup.KVBackup
import com.stevesoltys.backup.transport.backup.plugins.DocumentsProviderBackupPlugin
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.restore.FullRestore
import com.stevesoltys.backup.transport.restore.KVRestore
import com.stevesoltys.backup.transport.restore.OutputFactory
import com.stevesoltys.backup.transport.restore.RestoreCoordinator
import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin
class PluginManager(context: Context) {
// We can think about using an injection framework such as Dagger to simplify this.
private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName(context)!!)
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
private val inputFactory = InputFactory()
private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto)
private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
private val notificationManager = (context.applicationContext as Backup).notificationManager
internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
private val restorePlugin = DocumentsProviderRestorePlugin(storage)
private val outputFactory = OutputFactory()
private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
}

View file

@ -0,0 +1,147 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.BackupNotificationManager
import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class BackupCoordinator(
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
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()
// [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)
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
//
fun requestBackupTime() = kv.requestBackupTime()
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) =
kv.performBackup(packageInfo, data, flags)
// ------------------------------------------------------------------------------------
// Full backup
//
fun requestFullBackupTime() = full.requestFullBackupTime()
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) =
full.performFullBackup(targetPackage, fileDescriptor, flags)
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() -> {
if (full.hasState()) throw IllegalStateException()
kv.finishBackup()
}
full.hasState() -> {
if (kv.hasState()) throw IllegalStateException()
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
calledInitialize = false
calledClearBackupData = false
TRANSPORT_OK
}
else -> throw IllegalStateException()
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,21 @@
package com.stevesoltys.backup.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.
*/
class InputFactory {
fun getBackupDataInput(inputFileDescriptor: ParcelFileDescriptor): BackupDataInput {
return BackupDataInput(inputFileDescriptor.fileDescriptor)
}
fun getInputStream(inputFileDescriptor: ParcelFileDescriptor): InputStream {
return FileInputStream(inputFileDescriptor.fileDescriptor)
}
}

View file

@ -0,0 +1,199 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.*
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderWriter
import com.stevesoltys.backup.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
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 requestBackupTime(): Long {
Log.i(TAG, "Request K/V backup time")
return 0
}
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.encryptSegment(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 and encrypt value
val value = if (dataSize >= 0) {
Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key")
val bytes = ByteArray(dataSize)
val bytesRead = try {
changeSet.readEntityData(bytes, 0, dataSize)
} catch (e: IOException) {
Log.e(TAG, "Error reading entity data for key $key", e)
return@generateSequence Result.Error(e)
}
if (bytesRead != dataSize) {
Log.w(TAG, "Expecting $dataSize bytes, but only read $bytesRead.")
}
bytes
} else null
// add change operation to the sequence
Result.Ok(KVOperation(key, base64Key, value))
}
}
@Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
fun finishBackup(): Int {
Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}")
state = null
return TRANSPORT_OK
}
/**
* Method to reset state,
* because [finishBackup] is not called when we don't return [TRANSPORT_OK].
*/
private fun backupError(result: Int): Int {
Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageName}")
state = null
return result
}
private class KVOperation(
internal val key: String,
internal val base64Key: String,
/**
* value is null when this is a deletion operation
*/
internal val value: ByteArray?
)
private sealed class Result<out T> {
class Ok<out T>(val result: T) : Result<T>()
class Error(val exception: Exception) : Result<Nothing>()
}
}

View file

@ -0,0 +1,49 @@
package com.stevesoltys.backup.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)
}

View file

@ -0,0 +1,41 @@
package com.stevesoltys.backup.transport.backup.plugins
import android.content.pm.PackageManager
import com.stevesoltys.backup.transport.backup.BackupPlugin
import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException
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.defaultKvBackupDir
val fullDir = storage.defaultFullBackupDir
// wipe existing data
kvDir?.deleteContents()
fullDir?.deleteContents()
}
override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
providerInfo.packageName
}
}

View file

@ -0,0 +1,32 @@
package com.stevesoltys.backup.transport.backup.plugins
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import java.io.IOException
import java.io.OutputStream
private val TAG = DocumentsProviderFullBackup::class.java.simpleName
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.defaultFullBackupDir?.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.defaultFullBackupDir?.findFile(packageName) ?: return
if (!file.delete()) throw IOException("Failed to delete $packageName")
}
}

View file

@ -0,0 +1,53 @@
package com.stevesoltys.backup.transport.backup.plugins
import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException
import java.io.OutputStream
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.defaultKvBackupDir?.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.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return
packageFile.delete()
}
@Throws(IOException::class)
override fun deleteRecord(packageInfo: PackageInfo, key: String) {
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.findFile(key) ?: return
keyFile.delete()
}
@Throws(IOException::class)
override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream {
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.createOrGetFile(key)
return storage.getOutputStream(keyFile)
}
}

View file

@ -0,0 +1,127 @@
package com.stevesoltys.backup.transport.backup.plugins
import android.content.Context
import android.content.pm.PackageInfo
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
private const val ROOT_DIR_NAME = ".AndroidBackup"
private const val NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) {
private val contentResolver = context.contentResolver
internal val rootBackupDir: DocumentFile? by lazy {
val folderUri = parentFolder ?: return@lazy null
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
try {
val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
}
}
private val deviceDir: DocumentFile? by lazy {
try {
rootBackupDir?.createOrGetDirectory(deviceName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
private val defaultSetDir: DocumentFile? by lazy {
val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString()
try {
deviceDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
val defaultFullBackupDir: DocumentFile? by lazy {
try {
defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
val defaultKvBackupDir: DocumentFile? by lazy {
try {
defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir
return deviceDir?.findFile(token.toString())
}
fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
}
fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
}
@Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream {
return contentResolver.openInputStream(file.uri) ?: throw IOException()
}
@Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream {
return contentResolver.openOutputStream(file.uri) ?: 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()
}

View file

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

View file

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

View file

@ -1,367 +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.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.settings.SettingsManagerKt.getBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
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(getBackupPassword(context));
backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt()));
}
}
private void initializeOutputStream() throws IOException {
Uri folderUri = getBackupFolderUri(context);
// TODO notify about failure with notification
Uri fileUri = createBackupFile(folderUri);
ParcelFileDescriptor outputFileDescriptor = context.getContentResolver().openFileDescriptor(fileUri, "w");
if (outputFileDescriptor == null) throw new IOException();
backupState.setOutputFileDescriptor(outputFileDescriptor);
FileOutputStream fileOutputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);
backupState.setOutputStream(zipOutputStream);
}
private Uri createBackupFile(Uri folderUri) throws IOException {
Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri));
try {
Uri fileUri = createDocument(context.getContentResolver(), documentUri, DOCUMENT_MIME_TYPE, getBackupFileName());
if (fileUri == null) throw new IOException();
return fileUri;
} catch (SecurityException e) {
// happens when folder was deleted and thus Uri permission don't exist anymore
throw new IOException(e);
}
}
private String getBackupFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US);
String date = dateFormat.format(new Date());
return "backup-" + date;
}
private int clearBackupState(boolean closeFile) {
if (backupState == null) {
return TRANSPORT_OK;
}
try {
IoUtils.closeQuietly(backupState.getInputFileDescriptor());
backupState.setInputFileDescriptor(null);
ZipOutputStream outputStream = backupState.getOutputStream();
if (outputStream != null) {
if (backupState.getCipher() != null) {
outputStream.write(backupState.getCipher().doFinal());
backupState.setCipher(null);
}
outputStream.closeEntry();
}
if (closeFile) {
Log.d(TAG, "Closing backup file...");
if (outputStream != null) {
outputStream.finish();
outputStream.close();
}
IoUtils.closeQuietly(backupState.getOutputFileDescriptor());
backupState = null;
}
} catch (Exception ex) {
Log.e(TAG, "Error cancelling full backup: ", ex);
return TRANSPORT_ERROR;
}
return TRANSPORT_OK;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,172 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.*
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.header.HeaderReader
import com.stevesoltys.backup.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
private class FullRestoreState(
internal val token: Long,
internal val packageInfo: PackageInfo) {
internal var inputStream: InputStream? = null
}
private val TAG = FullRestore::class.java.simpleName
internal class FullRestore(
private val plugin: FullRestorePlugin,
private val outputFactory: OutputFactory,
private val headerReader: HeaderReader,
private val crypto: Crypto) {
private var state: FullRestoreState? = null
fun hasState() = state != null
/**
* Return true if there is data stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo)
}
/**
* This prepares to restore the given package from the given restore token.
*
* It is possible that the system decides to not restore the package.
* Then a new state will be initialized right away without calling other methods.
*/
fun initializeState(token: Long, packageInfo: PackageInfo) {
state = FullRestoreState(token, packageInfo)
}
/**
* Ask the transport to provide data for the "current" package being restored.
*
* The transport writes some data to the socket supplied to this call,
* and returns the number of bytes written.
* The system will then read that many bytes
* and stream them to the application's agent for restore,
* then will call this method again to receive the next chunk of the archive.
* This sequence will be repeated until the transport returns zero
* indicating that all of the package's data has been delivered
* (or returns a negative value indicating a hard error condition at the transport level).
*
* The transport should always close this socket when returning from this method.
* Do not cache this socket across multiple calls or you may leak file descriptors.
*
* @param socket The file descriptor for delivering the streamed archive.
* The transport must close this socket in all cases when returning from this method.
* @return [NO_MORE_DATA] when no more data for the current package is available.
* A positive value indicates the presence of that many bytes to be delivered to the app.
* A value of zero indicates that no data was deliverable at this time,
* but the restore is still running and the caller should retry.
* [TRANSPORT_PACKAGE_REJECTED] means that the package's restore operation should be aborted,
* but that the transport itself is still in a good state
* and so a multiple-package restore sequence can still be continued.
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
* that aborts all further restore operations on the current dataset.
*/
fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
Log.i(TAG, "Get next full restore data chunk.")
val state = this.state ?: throw IllegalStateException()
val packageName = state.packageInfo.packageName
if (state.inputStream == null) {
Log.i(TAG, "First Chunk, initializing package input stream.")
try {
val inputStream = plugin.getInputStreamForPackage(state.token, state.packageInfo)
val version = headerReader.readVersion(inputStream)
crypto.decryptHeader(inputStream, version, packageName)
state.inputStream = inputStream
} catch (e: IOException) {
Log.w(TAG, "Error getting input stream for $packageName", e)
return TRANSPORT_PACKAGE_REJECTED
} catch (e: SecurityException) {
Log.e(TAG, "Security Exception while getting input stream for $packageName", e)
return TRANSPORT_ERROR
} catch (e: UnsupportedVersionException) {
Log.e(TAG, "Backup data for $packageName uses unsupported version ${e.version}.", e)
return TRANSPORT_PACKAGE_REJECTED
}
}
return readInputStream(socket)
}
private fun readInputStream(socket: ParcelFileDescriptor): Int = socket.use { fileDescriptor ->
val state = this.state ?: throw IllegalStateException()
val packageName = state.packageInfo.packageName
val inputStream = state.inputStream ?: throw IllegalStateException()
val outputStream = outputFactory.getOutputStream(fileDescriptor)
try {
// read segment from input stream and decrypt it
val decrypted = try {
crypto.decryptSegment(inputStream)
} catch (e: EOFException) {
Log.i(TAG, " EOF")
// close input stream here as we won't need it anymore
closeQuietly(inputStream)
return NO_MORE_DATA
}
// write decrypted segment to output stream (without header)
outputStream.write(decrypted)
// return number of written bytes
return decrypted.size
} catch (e: IOException) {
Log.w(TAG, "Error processing stream for package $packageName.", e)
closeQuietly(inputStream)
return TRANSPORT_PACKAGE_REJECTED
} finally {
closeQuietly(outputStream)
}
}
/**
* If the OS encounters an error while processing full data for restore,
* it will invoke this method
* to tell the transport that it should abandon the data download for the current package.
*
* @return [TRANSPORT_OK] if the transport shut down the current stream cleanly,
* or [TRANSPORT_ERROR] to indicate a serious transport-level failure.
* If the transport reports an error here,
* the entire restore operation will immediately be finished
* with no further attempts to restore app data.
*/
fun abortFullRestore(): Int {
val state = this.state ?: throw IllegalStateException()
Log.i(TAG, "Abort full restore of ${state.packageInfo.packageName}!")
resetState()
return TRANSPORT_OK
}
/**
* End a restore session (aborting any in-process data transfer as necessary),
* freeing any resources and connections used during the restore process.
*/
fun finishRestore() {
val state = this.state ?: throw IllegalStateException()
Log.i(TAG, "Finish restore of ${state.packageInfo.packageName}!")
resetState()
}
private fun resetState() {
Log.i(TAG, "Resetting state.")
closeQuietly(state?.inputStream)
state = null
}
}

View file

@ -0,0 +1,18 @@
package com.stevesoltys.backup.transport.restore
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.InputStream
interface FullRestorePlugin {
/**
* Return true if there is data stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
@Throws(IOException::class)
fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
}

View file

@ -0,0 +1,140 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.decodeBase64
import com.stevesoltys.backup.header.HeaderReader
import com.stevesoltys.backup.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
import java.util.*
private class KVRestoreState(
internal val token: Long,
internal val packageInfo: PackageInfo)
private val TAG = KVRestore::class.java.simpleName
internal class KVRestore(
private val plugin: KVRestorePlugin,
private val outputFactory: OutputFactory,
private val headerReader: HeaderReader,
private val crypto: Crypto) {
private var state: KVRestoreState? = null
/**
* Return true if there are records stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo)
}
/**
* This prepares to restore the given package from the given restore token.
*
* It is possible that the system decides to not restore the package.
* Then a new state will be initialized right away without calling other methods.
*/
fun initializeState(token: Long, packageInfo: PackageInfo) {
state = KVRestoreState(token, packageInfo)
}
/**
* Get the data for the current package.
*
* @param data An open, writable file into which the key/value backup data should be stored.
* @return One of [TRANSPORT_OK]
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/
fun getRestoreData(data: ParcelFileDescriptor): Int {
val state = this.state ?: throw IllegalStateException()
// The restore set is the concatenation of the individual record blobs,
// each of which is a file in the package's directory.
// We return the data in lexical order sorted by key,
// so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
// will see the date in the most obvious order.
val sortedKeys = getSortedKeys(state.token, state.packageInfo)
if (sortedKeys == null) {
// nextRestorePackage() ensures the dir exists, so this is an error
Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
return TRANSPORT_ERROR
}
// We expect at least some data if the directory exists in the first place
Log.v(TAG, " getRestoreData() found ${sortedKeys.size} key files")
return try {
val dataOutput = outputFactory.getBackupDataOutput(data)
for (keyEntry in sortedKeys) {
readAndWriteValue(state, keyEntry, dataOutput)
}
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Unable to read backup records", e)
TRANSPORT_ERROR
} catch (e: SecurityException) {
Log.e(TAG, "Security exception while reading backup records", e)
TRANSPORT_ERROR
} catch (e: UnsupportedVersionException) {
Log.e(TAG, "Unsupported version in backup: ${e.version}", e)
TRANSPORT_ERROR
} finally {
this.state = null
closeQuietly(data)
}
}
/**
* Return a list of the records (represented by key files) in the given directory,
* sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
*/
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
val records: List<String> = try {
plugin.listRecords(token, packageInfo)
} catch (e: IOException) {
return null
}
if (records.isEmpty()) return null
// Decode the key filenames into keys then sort lexically by key
val contents = ArrayList<DecodedKey>()
for (recordKey in records) contents.add(DecodedKey(recordKey))
contents.sort()
return contents
}
/**
* Read the encrypted value for the given key and write it to the given [BackupDataOutput].
*/
@Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) {
val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
try {
val version = headerReader.readVersion(inputStream)
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
val value = crypto.decryptSegment(inputStream)
val size = value.size
Log.v(TAG, " ... key=${dKey.key} size=$size")
out.writeEntityHeader(dKey.key, size)
out.writeEntityData(value, size)
} finally {
closeQuietly(inputStream)
}
}
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
internal val key = base64Key.decodeBase64()
override fun compareTo(other: DecodedKey) = key.compareTo(other.key)
}
}

View file

@ -0,0 +1,30 @@
package com.stevesoltys.backup.transport.restore
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.InputStream
interface KVRestorePlugin {
/**
* Return true if there is data stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
/**
* Return all record keys for the given token and package.
*
* For file-based plugins, this is usually a list of file names in the package directory.
*/
@Throws(IOException::class)
fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
/**
* Return an [InputStream] for the given token, package and key
* which will provide the record's encrypted value.
*/
@Throws(IOException::class)
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
}

View file

@ -0,0 +1,21 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupDataOutput
import android.os.ParcelFileDescriptor
import java.io.FileOutputStream
import java.io.OutputStream
/**
* This class exists for easier testing, so we can mock it and return custom data outputs.
*/
class OutputFactory {
fun getBackupDataOutput(outputFileDescriptor: ParcelFileDescriptor): BackupDataOutput {
return BackupDataOutput(outputFileDescriptor.fileDescriptor)
}
fun getOutputStream(outputFileDescriptor: ParcelFileDescriptor): OutputStream {
return FileOutputStream(outputFileDescriptor.fileDescriptor)
}
}

View file

@ -0,0 +1,155 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import java.io.IOException
private class RestoreCoordinatorState(
internal val token: Long,
internal val packages: Iterator<PackageInfo>)
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore) {
private var state: RestoreCoordinatorState? = null
fun getAvailableRestoreSets(): Array<RestoreSet>? {
return plugin.getAvailableRestoreSets()
.apply { Log.i(TAG, "Got available restore sets: $this") }
}
fun getCurrentRestoreSet(): Long {
return plugin.getCurrentRestoreSet()
.apply { Log.i(TAG, "Got current restore set token: $this") }
}
/**
* Start restoring application data from backup.
* After calling this function,
* there will be alternate calls to [nextRestorePackage] and [getRestoreData]
* to walk through the actual application data.
*
* @param token A backup token as returned by [getAvailableRestoreSets] or [getCurrentRestoreSet].
* @param packages List of applications to restore (if data is available).
* Application data will be restored in the order given.
* @return One of [TRANSPORT_OK] (OK so far, call [nextRestorePackage])
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
if (state != null) throw IllegalStateException()
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
state = RestoreCoordinatorState(token, packages.iterator())
return TRANSPORT_OK
}
/**
* Get the package name of the next package with data in the backup store,
* plus a description of the structure of the restored archive:
* either [TYPE_KEY_VALUE] for an original-API key/value dataset,
* or [TYPE_FULL_STREAM] for a tarball-type archive stream.
*
* If the package name in the returned [RestoreDescription] object is [NO_MORE_PACKAGES],
* it indicates that no further data is available in the current restore session,
* i.e. all packages described in [startRestore] have been processed.
*
* If this method returns null, it means that a transport-level error has
* occurred and the entire restore operation should be abandoned.
*
* The OS may call [nextRestorePackage] multiple times
* before calling either [getRestoreData] or [getNextFullRestoreDataChunk].
* It does this when it has determined
* that it needs to skip restore of one or more packages.
* The transport should not actually transfer any restore data
* for the given package in response to [nextRestorePackage],
* but rather wait for an explicit request before doing so.
*
* @return A [RestoreDescription] object containing the name of one of the packages
* supplied to [startRestore] plus an indicator of the data type of that restore data;
* or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
* or null to indicate a transport-level error.
*/
fun nextRestorePackage(): RestoreDescription? {
Log.i(TAG, "Next restore package!")
val state = this.state ?: throw IllegalStateException()
if (!state.packages.hasNext()) return NO_MORE_PACKAGES
val packageInfo = state.packages.next()
val packageName = packageInfo.packageName
val type = try {
when {
// check key/value data first and if available, don't even check for full data
kv.hasDataForPackage(state.token, packageInfo) -> {
Log.i(TAG, "Found K/V data for $packageName.")
kv.initializeState(state.token, packageInfo)
TYPE_KEY_VALUE
}
full.hasDataForPackage(state.token, packageInfo) -> {
Log.i(TAG, "Found full backup data for $packageName.")
full.initializeState(state.token, packageInfo)
TYPE_FULL_STREAM
}
else -> {
Log.i(TAG, "No data found for $packageName. Skipping.")
return nextRestorePackage()
}
}
} catch (e: IOException) {
Log.e(TAG, "Error finding restore data for $packageName.", e)
return null
}
return RestoreDescription(packageName, type)
}
/**
* Get the data for the application returned by [nextRestorePackage],
* if that method reported [TYPE_KEY_VALUE] as its delivery type.
* If the package has only TYPE_FULL_STREAM data, then this method will return an error.
*
* @param data An open, writable file into which the key/value backup data should be stored.
* @return the same error codes as [startRestore].
*/
fun getRestoreData(data: ParcelFileDescriptor): Int {
return kv.getRestoreData(data)
}
/**
* Ask the transport to provide data for the "current" package being restored.
*
* After this method returns zero, the system will then call [nextRestorePackage]
* to begin the restore process for the next application, and the sequence begins again.
*/
fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
return full.getNextFullRestoreDataChunk(outputFileDescriptor)
}
/**
* If the OS encounters an error while processing full data for restore, it will abort.
*
* The OS will then either call [nextRestorePackage] again to move on
* to restoring the next package in the set being iterated over,
* or will call [finishRestore] to shut down the restore operation.
*/
fun abortFullRestore(): Int {
return full.abortFullRestore()
}
/**
* End a restore session (aborting any in-process data transfer as necessary),
* freeing any resources and connections used during the restore process.
*/
fun finishRestore() {
if (full.hasState()) full.finishRestore()
}
}

View file

@ -0,0 +1,28 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.RestoreSet
interface RestorePlugin {
val kvRestorePlugin: KVRestorePlugin
val fullRestorePlugin: FullRestorePlugin
/**
* Get the set of all backups currently available for restore.
*
* @return Descriptions of the set of restore images available for this device,
* or null if an error occurred (the attempt should be rescheduled).
**/
fun getAvailableRestoreSets(): Array<RestoreSet>?
/**
* Get the identifying token of the backup set currently being stored from this device.
* This is used in the case of applications wishing to restore their last-known-good data.
*
* @return A token that can be used for restore,
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long
}

View file

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

View file

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

View file

@ -0,0 +1,29 @@
package com.stevesoltys.backup.transport.restore.plugins
import android.app.backup.RestoreSet
import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.restore.FullRestorePlugin
import com.stevesoltys.backup.transport.restore.KVRestorePlugin
import com.stevesoltys.backup.transport.restore.RestorePlugin
class DocumentsProviderRestorePlugin(
private val documentsStorage: DocumentsStorage) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(documentsStorage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(documentsStorage)
}
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN))
}
override fun getCurrentRestoreSet(): Long {
return DEFAULT_RESTORE_SET_TOKEN
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19,20H6C2.71,20 0,17.29 0,14C0,10.9 2.34,8.36 5.35,8.03C6.6,5.64 9.11,4 12,4C15.64,4 18.67,6.59 19.35,10.03C21.95,10.22 24,12.36 24,15C24,17.74 21.74,20 19,20M11,15V17H13V15H11M11,13H13V8H11V13Z" />
</vector>

View file

@ -26,6 +26,7 @@
<!-- Settings -->
<string name="settings_backup">Backup my data</string>
<string name="settings_backup_location">Backup location</string>
<string name="settings_backup_location_picker">Choose backup location</string>
<string name="settings_backup_location_title">Backup Location</string>
<string name="settings_backup_location_info">Choose where to store your backups. More options might get added in the future.</string>
<string name="settings_backup_external_storage">External Storage</string>
@ -61,6 +62,12 @@
<string name="notification_channel_title">Backup Notification</string>
<string name="notification_title">Backup running</string>
<string name="notification_backup_result_complete">Backup complete</string>
<string name="notification_backup_result_rejected">Not backed up</string>
<string name="notification_backup_result_error">Backup failed</string>
<string name="notification_error_channel_title">Error Notification</string>
<string name="notification_error_title">Backup Error</string>
<string name="notification_error_text">A device backup failed to run.</string>
<string name="notification_error_action">Fix</string>
</resources>

View file

@ -0,0 +1,36 @@
package com.stevesoltys.backup
import kotlin.random.Random
fun assertContains(stack: String?, needle: String) {
if (stack?.contains(needle) != true) throw AssertionError()
}
fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply {
Random.nextBytes(this)
}
private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
return (1..size)
.map { Random.nextInt(0, charPool.size) }
.map(charPool::get)
.joinToString("")
}
fun ByteArray.toHexString(): String {
var str = ""
for (b in this) {
str += String.format("%02X ", b)
}
return str
}
fun ByteArray.toIntString(): String {
var str = ""
for (b in this) {
str += String.format("%02d ", b)
}
return str
}

View file

@ -0,0 +1,26 @@
package com.stevesoltys.backup.crypto
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class KeyManagerTestImpl : KeyManager {
private val key by lazy {
val keyGenerator = KeyGenerator.getInstance("AES")
keyGenerator.init(KEY_SIZE)
keyGenerator.generateKey()
}
override fun storeBackupKey(seed: ByteArray) {
throw NotImplementedError("not implemented")
}
override fun hasBackupKey(): Boolean {
return true
}
override fun getBackupKey(): SecretKey {
return key
}
}

View file

@ -0,0 +1,53 @@
package com.stevesoltys.backup.crypto
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.header.IV_SIZE
import com.stevesoltys.backup.header.MAX_SEGMENT_LENGTH
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import javax.crypto.Cipher
import kotlin.random.Random
@TestInstance(PER_METHOD)
class CryptoImplTest {
private val cipherFactory = mockk<CipherFactory>()
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val cipher = mockk<Cipher>()
private val iv = ByteArray(IV_SIZE).apply { Random.nextBytes(this) }
private val cleartext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt()))
.apply { Random.nextBytes(this) }
private val ciphertext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt()))
.apply { Random.nextBytes(this) }
private val outputStream = ByteArrayOutputStream()
@Test
fun `encrypted cleartext gets decrypted as expected`() {
every { cipherFactory.createEncryptionCipher() } returns cipher
every { cipher.getOutputSize(cleartext.size) } returns MAX_SEGMENT_LENGTH
every { cipher.doFinal(cleartext) } returns ciphertext
every { cipher.iv } returns iv
crypto.encryptSegment(outputStream, cleartext)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(ciphertext) } returns cleartext
assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
}
}

View file

@ -0,0 +1,44 @@
package com.stevesoltys.backup.crypto
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@TestInstance(PER_METHOD)
class CryptoIntegrationTest {
private val keyManager = KeyManagerTestImpl()
private val cipherFactory = CipherFactoryImpl(keyManager)
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val cleartext = byteArrayOf(0x01, 0x02, 0x03)
private val outputStream = ByteArrayOutputStream()
@Test
fun `the plain crypto works`() {
val eCipher = cipherFactory.createEncryptionCipher()
val encrypted = eCipher.doFinal(cleartext)
val dCipher = cipherFactory.createDecryptionCipher(eCipher.iv)
val decrypted = dCipher.doFinal(encrypted)
assertArrayEquals(cleartext, decrypted)
}
@Test
fun `encrypted cleartext gets decrypted as expected`() {
crypto.encryptSegment(outputStream, cleartext)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
}
}

View file

@ -0,0 +1,192 @@
package com.stevesoltys.backup.crypto
import com.stevesoltys.backup.assertContains
import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.header.*
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import java.io.*
import javax.crypto.Cipher
import kotlin.random.Random
@TestInstance(PER_METHOD)
class CryptoTest {
private val cipherFactory = mockk<CipherFactory>()
private val headerWriter = mockk<HeaderWriter>()
private val headerReader = mockk<HeaderReader>()
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val cipher = mockk<Cipher>()
private val iv = getRandomByteArray(IV_SIZE)
private val cleartext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH))
private val ciphertext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH))
private val versionHeader = VersionHeader(VERSION, getRandomString(MAX_PACKAGE_LENGTH_SIZE), getRandomString(MAX_KEY_LENGTH_SIZE))
private val versionCiphertext = getRandomByteArray(MAX_VERSION_HEADER_SIZE)
private val versionSegmentHeader = SegmentHeader(versionCiphertext.size.toShort(), iv)
private val outputStream = ByteArrayOutputStream()
private val segmentHeader = SegmentHeader(ciphertext.size.toShort(), iv)
// the headerReader will not actually read the header, so only insert cipher text
private val inputStream = ByteArrayInputStream(ciphertext)
private val versionInputStream = ByteArrayInputStream(versionCiphertext)
// encrypting
@Test
fun `encrypt header works as expected`() {
val segmentHeader = CapturingSlot<SegmentHeader>()
every { headerWriter.getEncodedVersionHeader(versionHeader) } returns ciphertext
encryptSegmentHeader(ciphertext, segmentHeader)
crypto.encryptHeader(outputStream, versionHeader)
assertArrayEquals(iv, segmentHeader.captured.nonce)
assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt())
}
@Test
fun `encrypting segment works as expected`() {
val segmentHeader = CapturingSlot<SegmentHeader>()
encryptSegmentHeader(cleartext, segmentHeader)
crypto.encryptSegment(outputStream, cleartext)
assertArrayEquals(ciphertext, outputStream.toByteArray())
assertArrayEquals(iv, segmentHeader.captured.nonce)
assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt())
}
private fun encryptSegmentHeader(toEncrypt: ByteArray, segmentHeader: CapturingSlot<SegmentHeader>) {
every { cipherFactory.createEncryptionCipher() } returns cipher
every { cipher.getOutputSize(toEncrypt.size) } returns toEncrypt.size
every { cipher.iv } returns iv
every { headerWriter.writeSegmentHeader(outputStream, capture(segmentHeader)) } just Runs
every { cipher.doFinal(toEncrypt) } returns ciphertext
}
// decrypting
@Test
fun `decrypting header works as expected`() {
every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(versionCiphertext) } returns cleartext
every { headerReader.getVersionHeader(cleartext) } returns versionHeader
assertEquals(
versionHeader,
crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key)
)
}
@Test
fun `decrypting header throws if too large`() {
val size = MAX_VERSION_HEADER_SIZE + 1
val versionCiphertext = getRandomByteArray(size)
val versionInputStream = ByteArrayInputStream(versionCiphertext)
val versionSegmentHeader = SegmentHeader(size.toShort(), iv)
every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
val e = assertThrows(SecurityException::class.java) {
crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key)
}
assertContains(e.message, size.toString())
}
@Test
fun `decrypting header throws because of different version`() {
every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(versionCiphertext) } returns cleartext
every { headerReader.getVersionHeader(cleartext) } returns versionHeader
val version = (VERSION + 1).toByte()
val e = assertThrows(SecurityException::class.java) {
crypto.decryptHeader(versionInputStream, version, versionHeader.packageName, versionHeader.key)
}
assertContains(e.message, version.toString())
}
@Test
fun `decrypting header throws because of different package name`() {
every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(versionCiphertext) } returns cleartext
every { headerReader.getVersionHeader(cleartext) } returns versionHeader
val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
val e = assertThrows(SecurityException::class.java) {
crypto.decryptHeader(versionInputStream, versionHeader.version, packageName, versionHeader.key)
}
assertContains(e.message, packageName)
}
@Test
fun `decrypting header throws because of different key`() {
every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(versionCiphertext) } returns cleartext
every { headerReader.getVersionHeader(cleartext) } returns versionHeader
val e = assertThrows(SecurityException::class.java) {
crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, null)
}
assertContains(e.message, "null")
assertContains(e.message, versionHeader.key ?: fail())
}
@Test
fun `decrypting data segment header works as expected`() {
every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
every { cipherFactory.createDecryptionCipher(iv) } returns cipher
every { cipher.doFinal(ciphertext) } returns cleartext
assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
}
@Test
fun `decrypting data segment throws if reading 0 bytes`() {
val inputStream = mockk<InputStream>()
val buffer = ByteArray(segmentHeader.segmentLength.toInt())
every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
every { inputStream.read(buffer) } returns 0
assertThrows(IOException::class.java) {
crypto.decryptSegment(inputStream)
}
}
@Test
fun `decrypting data segment throws if reaching end of stream`() {
val inputStream = mockk<InputStream>()
val buffer = ByteArray(segmentHeader.segmentLength.toInt())
every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
every { inputStream.read(buffer) } returns -1
assertThrows(EOFException::class.java) {
crypto.decryptSegment(inputStream)
}
}
@Test
fun `decrypting data segment throws if reading less than expected`() {
val inputStream = mockk<InputStream>()
val buffer = ByteArray(segmentHeader.segmentLength.toInt())
every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
every { inputStream.read(buffer) } returns buffer.size - 1
assertThrows(IOException::class.java) {
crypto.decryptSegment(inputStream)
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,275 @@
package com.stevesoltys.backup.header
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.assertContains
import com.stevesoltys.backup.getRandomString
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import java.io.ByteArrayInputStream
import java.io.IOException
import java.nio.ByteBuffer
import kotlin.random.Random
@TestInstance(PER_CLASS)
internal class HeaderReaderTest {
private val reader = HeaderReaderImpl()
// Version Tests
@Test
fun `valid version is read`() {
val input = byteArrayOf(VERSION)
val inputStream = ByteArrayInputStream(input)
assertEquals(VERSION, reader.readVersion(inputStream))
}
@Test
fun `too short version stream throws exception`() {
val input = ByteArray(0)
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readVersion(inputStream)
}
}
@Test
fun `unsupported version throws exception`() {
val input = byteArrayOf((VERSION + 1).toByte())
val inputStream = ByteArrayInputStream(input)
assertThrows(UnsupportedVersionException::class.javaObjectType) {
reader.readVersion(inputStream)
}
}
@Test
fun `negative version throws exception`() {
val input = byteArrayOf((-1).toByte())
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readVersion(inputStream)
}
}
@Test
fun `max version byte throws exception`() {
val input = byteArrayOf(Byte.MAX_VALUE)
val inputStream = ByteArrayInputStream(input)
assertThrows(UnsupportedVersionException::class.javaObjectType) {
reader.readVersion(inputStream)
}
}
// VersionHeader Tests
@Test
fun `valid VersionHeader is read`() {
val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62)
val versionHeader = VersionHeader(VERSION, "a", "b")
assertEquals(versionHeader, reader.getVersionHeader(input))
}
@Test
fun `zero package length in VersionHeader throws`() {
val input = byteArrayOf(VERSION, 0x00, 0x00, 0x00, 0x01, 0x62)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `negative package length in VersionHeader throws`() {
val input = byteArrayOf(0x00, 0xFF, 0xFF, 0x00, 0x01, 0x62)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `too large package length in VersionHeader throws`() {
val size = MAX_PACKAGE_LENGTH_SIZE + 1
val input = ByteBuffer.allocate(3 + size)
.put(VERSION)
.putShort(size.toShort())
.put(ByteArray(size))
.array()
val e = assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
assertContains(e.message, size.toString())
}
@Test
fun `insufficient bytes for package in VersionHeader throws`() {
val input = byteArrayOf(VERSION, 0x00, 0x50)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `zero key length in VersionHeader gets accepted`() {
val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x00)
val versionHeader = VersionHeader(VERSION, "a", null)
assertEquals(versionHeader, reader.getVersionHeader(input))
}
@Test
fun `negative key length in VersionHeader throws`() {
val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0xFF, 0xFF)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `too large key length in VersionHeader throws`() {
val size = MAX_KEY_LENGTH_SIZE + 1
val input = ByteBuffer.allocate(4 + size)
.put(VERSION)
.putShort(1.toShort())
.put("a".toByteArray(Utf8))
.putShort(size.toShort())
.array()
val e = assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
assertContains(e.message, size.toString())
}
@Test
fun `insufficient bytes for key in VersionHeader throws`() {
val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0x00, 0x50)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `extra bytes in VersionHeader throws`() {
val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62, 0x00)
assertThrows(SecurityException::class.javaObjectType) {
reader.getVersionHeader(input)
}
}
@Test
fun `max sized VersionHeader gets accepted`() {
val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
val key = getRandomString(MAX_KEY_LENGTH_SIZE)
val input = ByteBuffer.allocate(MAX_VERSION_HEADER_SIZE)
.put(VERSION)
.putShort(MAX_PACKAGE_LENGTH_SIZE.toShort())
.put(packageName.toByteArray(Utf8))
.putShort(MAX_KEY_LENGTH_SIZE.toShort())
.put(key.toByteArray(Utf8))
.array()
assertEquals(MAX_VERSION_HEADER_SIZE, input.size)
val h = reader.getVersionHeader(input)
assertEquals(VERSION, h.version)
assertEquals(packageName, h.packageName)
assertEquals(key, h.key)
}
// SegmentHeader Tests
@Test
fun `too short SegmentHeader throws exception`() {
val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readSegmentHeader(inputStream)
}
}
@Test
fun `segment length of zero is rejected`() {
val input = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readSegmentHeader(inputStream)
}
}
@Test
fun `negative segment length is rejected`() {
val input = byteArrayOf(0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readSegmentHeader(inputStream)
}
}
@Test
fun `minimum negative segment length is rejected`() {
val input = byteArrayOf(0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertThrows(IOException::class.javaObjectType) {
reader.readSegmentHeader(inputStream)
}
}
@Test
fun `max segment length is accepted`() {
val input = byteArrayOf(0x7F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertEquals(MAX_SEGMENT_LENGTH, reader.readSegmentHeader(inputStream).segmentLength.toInt())
}
@Test
fun `min segment length of 1 is accepted`() {
val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val inputStream = ByteArrayInputStream(input)
assertEquals(1, reader.readSegmentHeader(inputStream).segmentLength.toInt())
}
@Test
fun `segment length is always read correctly`() {
val segmentLength = getRandomValidSegmentLength()
val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
.putShort(segmentLength)
.put(ByteArray(IV_SIZE))
.array()
val inputStream = ByteArrayInputStream(input)
assertEquals(segmentLength, reader.readSegmentHeader(inputStream).segmentLength)
}
@Test
fun `nonce is read in big endian`() {
val nonce = byteArrayOf(0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
val input = byteArrayOf(0x00, 0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
val inputStream = ByteArrayInputStream(input)
assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce)
}
@Test
fun `nonce is always read correctly`() {
val nonce = ByteArray(IV_SIZE).apply { Random.nextBytes(this) }
val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
.putShort(1)
.put(nonce)
.array()
val inputStream = ByteArrayInputStream(input)
assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce)
}
private fun byteArrayOf(vararg elements: Int): ByteArray {
return elements.map { it.toByte() }.toByteArray()
}
}
internal fun getRandomValidSegmentLength(): Short {
return Random.nextInt(1, Short.MAX_VALUE.toInt()).toShort()
}

View file

@ -0,0 +1,102 @@
package com.stevesoltys.backup.header
import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.getRandomString
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@TestInstance(PER_CLASS)
internal class HeaderWriterReaderTest {
private val writer = HeaderWriterImpl()
private val reader = HeaderReaderImpl()
private val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val versionHeader = VersionHeader(VERSION, packageName, key)
private val unsupportedVersionHeader = VersionHeader((VERSION + 1).toByte(), packageName)
private val segmentLength = getRandomValidSegmentLength()
private val nonce = getRandomByteArray(IV_SIZE)
private val segmentHeader = SegmentHeader(segmentLength, nonce)
@Test
fun `written version matches read input`() {
assertEquals(versionHeader.version, readWriteVersion(versionHeader))
}
@Test
fun `reading unsupported version throws exception`() {
assertThrows(UnsupportedVersionException::class.javaObjectType) {
readWriteVersion(unsupportedVersionHeader)
}
}
@Test
fun `VersionHeader output matches read input`() {
assertEquals(versionHeader, readWrite(versionHeader))
}
@Test
fun `VersionHeader with no key output matches read input`() {
val versionHeader = VersionHeader(VERSION, packageName, null)
assertEquals(versionHeader, readWrite(versionHeader))
}
@Test
fun `VersionHeader with empty package name throws`() {
val versionHeader = VersionHeader(VERSION, "")
assertThrows(SecurityException::class.java) {
readWrite(versionHeader)
}
}
@Test
fun `SegmentHeader constructor needs right IV size`() {
val nonceTooBig = ByteArray(IV_SIZE + 1).apply { Random.nextBytes(this) }
assertThrows(IllegalStateException::class.javaObjectType) {
SegmentHeader(segmentLength, nonceTooBig)
}
val nonceTooSmall = ByteArray(IV_SIZE - 1).apply { Random.nextBytes(this) }
assertThrows(IllegalStateException::class.javaObjectType) {
SegmentHeader(segmentLength, nonceTooSmall)
}
}
@Test
fun `SegmentHeader output matches read input`() {
assertEquals(segmentHeader, readWriteVersion(segmentHeader))
}
private fun readWriteVersion(header: VersionHeader): Byte {
val outputStream = ByteArrayOutputStream()
writer.writeVersion(outputStream, header)
val written = outputStream.toByteArray()
val inputStream = ByteArrayInputStream(written)
return reader.readVersion(inputStream)
}
private fun readWrite(header: VersionHeader): VersionHeader {
val written = writer.getEncodedVersionHeader(header)
return reader.getVersionHeader(written)
}
private fun readWriteVersion(header: SegmentHeader): SegmentHeader {
val outputStream = ByteArrayOutputStream()
writer.writeSegmentHeader(outputStream, header)
val written = outputStream.toByteArray()
val inputStream = ByteArrayInputStream(written)
return reader.readSegmentHeader(inputStream)
}
private fun assertEquals(expected: SegmentHeader, actual: SegmentHeader) {
assertEquals(expected.segmentLength, actual.segmentLength)
assertArrayEquals(expected.nonce, actual.nonce)
}
}

View file

@ -0,0 +1,163 @@
package com.stevesoltys.backup.transport
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.NO_MORE_DATA
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.crypto.KeyManagerTestImpl
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.*
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.random.Random
internal class CoordinatorIntegrationTest : TransportTest() {
private val inputFactory = mockk<InputFactory>()
private val outputFactory = mockk<OutputFactory>()
private val keyManager = KeyManagerTestImpl()
private val cipherFactory = CipherFactoryImpl(keyManager)
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>()
private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
private val restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
private val token = DEFAULT_RESTORE_SET_TOKEN
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val key = "RestoreKey"
private val key64 = key.encodeBase64()
private val key2 = "RestoreKey2"
private val key264 = key2.encodeBase64()
init {
every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin
every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin
}
@Test
fun `test key-value backup and restore with 2 records`() {
val value = CapturingSlot<ByteArray>()
val value2 = CapturingSlot<ByteArray>()
val bOutputStream = ByteArrayOutputStream()
val bOutputStream2 = ByteArrayOutputStream()
// read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen true andThen false
every { backupDataInput.key } returns key andThen key2
every { backupDataInput.dataSize } returns appData.size andThen appData2.size
every { backupDataInput.readEntityData(capture(value), 0, appData.size) } answers {
appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers {
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
assertEquals(TRANSPORT_OK, backup.finishBackup())
// start restore
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType)
// restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key264) } returns rInputStream2
every { backupDataOutput.writeEntityHeader(key2, appData2.size) } returns 1137
every { backupDataOutput.writeEntityData(appData2, appData2.size) } returns appData2.size
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
}
@Test
fun `test full backup and restore with two chunks`() {
// return streams from plugin and app data
val bOutputStream = ByteArrayOutputStream()
val bInputStream = ByteArrayInputStream(appData)
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
// perform backup to output stream
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2))
assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2))
assertEquals(TRANSPORT_OK, backup.finishBackup())
// start restore
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data only for full backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false
every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
assertEquals(TYPE_FULL_STREAM, restoreDescription.dataType)
// reverse the backup streams into restore input
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rOutputStream = ByteArrayOutputStream()
every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream
every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
// restore data
assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor))
assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor))
assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
restore.finishRestore()
// assert that restored data matches original app data
assertArrayEquals(appData, rOutputStream.toByteArray())
}
}

View file

@ -0,0 +1,30 @@
package com.stevesoltys.backup.transport
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
@TestInstance(PER_METHOD)
abstract class TransportTest {
protected val crypto = mockk<Crypto>()
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }
init {
mockkStatic(Log::class)
every { Log.v(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), ofType(String::class)) } returns 0
every { Log.w(any(), ofType(String::class), any()) } returns 0
every { Log.e(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
}
}

View file

@ -0,0 +1,113 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.BackupNotificationManager
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(plugin, kv, full, notificationManager)
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertEquals(TRANSPORT_OK, backup.initializeDevice())
assertEquals(TRANSPORT_OK, backup.finishBackup())
}
@Test
fun `device initialization fails`() {
every { plugin.initializeDevice() } throws IOException()
every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
// finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) {
backup.finishBackup()
}
}
@Test
fun `getBackupQuota() delegates to right plugin`() {
val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong()
if (isFullBackup) {
every { full.getQuota() } returns quota
} else {
every { kv.getQuota() } returns quota
}
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
}
@Test
fun `clearing KV backup data throws`() {
every { kv.clearBackupData(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
}
@Test
fun `clearing full backup data throws`() {
every { kv.clearBackupData(packageInfo) } just Runs
every { full.clearBackupData(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
}
@Test
fun `clearing backup data succeeds`() {
every { kv.clearBackupData(packageInfo) } just Runs
every { full.clearBackupData(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertEquals(TRANSPORT_OK, backup.finishBackup())
}
@Test
fun `finish backup delegates to KV plugin if it has state`() {
val result = Random.nextInt()
every { kv.hasState() } returns true
every { full.hasState() } returns false
every { kv.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
}
@Test
fun `finish backup delegates to full plugin if it has state`() {
val result = Random.nextInt()
every { kv.hasState() } returns false
every { full.hasState() } returns true
every { full.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
}
}

View file

@ -0,0 +1,20 @@
package com.stevesoltys.backup.transport.backup
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.transport.TransportTest
import com.stevesoltys.backup.header.HeaderWriter
import com.stevesoltys.backup.header.VersionHeader
import io.mockk.mockk
import java.io.OutputStream
internal abstract class BackupTest : TransportTest() {
protected val inputFactory = mockk<InputFactory>()
protected val headerWriter = mockk<HeaderWriter>()
protected val data = mockk<ParcelFileDescriptor>()
protected val outputStream = mockk<OutputStream>()
protected val header = VersionHeader(packageName = packageInfo.packageName)
protected val quota = 42L
}

View file

@ -0,0 +1,283 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.*
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.FileInputStream
import java.io.IOException
import kotlin.random.Random
internal class FullBackupTest : BackupTest() {
private val plugin = mockk<FullBackupPlugin>()
private val backup = FullBackup(plugin, inputFactory, headerWriter, crypto)
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) }
private val inputStream = mockk<FileInputStream>()
@Test
fun `now is a good time for a backup`() {
assertEquals(0, backup.requestFullBackupTime())
}
@Test
fun `has no initial state`() {
assertFalse(backup.hasState())
}
@Test
fun `checkFullBackupSize exceeds quota`() {
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(quota + 1))
}
@Test
fun `checkFullBackupSize for no data`() {
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
}
@Test
fun `checkFullBackupSize for negative data`() {
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(-1))
}
@Test
fun `checkFullBackupSize accepts min data`() {
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(1))
}
@Test
fun `checkFullBackupSize accepts max data`() {
every { plugin.getQuota() } returns quota
assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota))
}
@Test
fun `performFullBackup throws exception when getting outputStream`() {
every { plugin.getOutputStream(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data))
assertFalse(backup.hasState())
}
@Test
fun `performFullBackup throws exception when writing header`() {
every { plugin.getOutputStream(packageInfo) } returns outputStream
every { inputFactory.getInputStream(data) } returns inputStream
every { headerWriter.writeVersion(outputStream, header) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data))
assertFalse(backup.hasState())
}
@Test
fun `performFullBackup runs ok`() {
expectPerformFullBackup()
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData first call over quota`() {
expectPerformFullBackup()
val numBytes = (quota + 1).toInt()
expectSendData(numBytes)
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData second call over quota`() {
expectPerformFullBackup()
val numBytes1 = quota.toInt()
expectSendData(numBytes1)
val numBytes2 = 1
expectSendData(numBytes2)
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes2))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData throws exception when reading from InputStream`() {
expectPerformFullBackup()
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } throws IOException()
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData throws exception when writing encrypted data to OutputStream`() {
expectPerformFullBackup()
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } returns bytes.size
every { crypto.encryptSegment(outputStream, any()) } throws IOException()
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData runs ok`() {
expectPerformFullBackup()
val numBytes1 = (quota / 2).toInt()
expectSendData(numBytes1)
val numBytes2 = (quota / 2).toInt()
expectSendData(numBytes2)
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `clearBackupData delegates to plugin`() {
every { plugin.removeDataOfPackage(packageInfo) } just Runs
backup.clearBackupData(packageInfo)
}
@Test
fun `cancel full backup runs ok`() {
expectPerformFullBackup()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
backup.cancelFullBackup()
assertFalse(backup.hasState())
}
@Test
fun `cancel full backup ignores exception when calling plugin`() {
expectPerformFullBackup()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
backup.cancelFullBackup()
assertFalse(backup.hasState())
}
@Test
fun `clearState throws exception when flushing OutputStream`() {
expectPerformFullBackup()
every { outputStream.write(closeBytes) } just Runs
every { outputStream.flush() } throws IOException()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_ERROR, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `clearState ignores exception when closing OutputStream`() {
expectPerformFullBackup()
every { outputStream.flush() } just Runs
every { outputStream.close() } throws IOException()
every { inputStream.close() } just Runs
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `clearState ignores exception when closing InputStream`() {
expectPerformFullBackup()
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
every { inputStream.close() } throws IOException()
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `clearState ignores exception when closing ParcelFileDescriptor`() {
expectPerformFullBackup()
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
every { inputStream.close() } just Runs
every { data.close() } throws IOException()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
private fun expectPerformFullBackup() {
every { plugin.getOutputStream(packageInfo) } returns outputStream
every { inputFactory.getInputStream(data) } returns inputStream
every { headerWriter.writeVersion(outputStream, header) } just Runs
every { crypto.encryptHeader(outputStream, header) } just Runs
}
private fun expectSendData(numBytes: Int, readBytes: Int = numBytes) {
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), numBytes) } returns readBytes
every { crypto.encryptSegment(outputStream, any()) } just Runs
}
private fun expectClearState() {
every { outputStream.write(closeBytes) } just Runs
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
every { inputStream.close() } just Runs
every { data.close() } just Runs
}
}

View file

@ -0,0 +1,216 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupDataInput
import android.app.backup.BackupTransport.*
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.backup.header.VersionHeader
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
import java.util.*
import kotlin.random.Random
internal class KVBackupTest : BackupTest() {
private val plugin = mockk<KVBackupPlugin>()
private val dataInput = mockk<BackupDataInput>()
private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto)
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
private val value = ByteArray(23).apply { Random.nextBytes(this) }
private val versionHeader = VersionHeader(packageName = packageInfo.packageName, key = key)
@Test
fun `now is a good time for a backup`() {
assertEquals(0, backup.requestBackupTime())
}
@Test
fun `has no initial state`() {
assertFalse(backup.hasState())
}
@Test
fun `simple backup with one record`() {
singleRecordBackup()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `incremental backup with no data gets rejected`() {
every { plugin.hasDataForPackage(packageInfo) } returns false
assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL))
assertFalse(backup.hasState())
}
@Test
fun `check for existing data throws exception`() {
every { plugin.hasDataForPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `non-incremental backup with data clears old data first`() {
singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `ignoring exception when clearing data when non-incremental backup has data`() {
singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `ensuring storage throws exception`() {
every { plugin.hasDataForPackage(packageInfo) } returns false
every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `exception while reading next header`() {
initPlugin(false)
createBackupDataInput()
every { dataInput.readNextHeader() } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `exception while reading value`() {
initPlugin(false)
createBackupDataInput()
every { dataInput.readNextHeader() } returns true
every { dataInput.key } returns key
every { dataInput.dataSize } returns value.size
every { dataInput.readEntityData(any(), 0, value.size) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `no data records`() {
initPlugin(false)
getDataInput(listOf(false))
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `exception while writing version header`() {
initPlugin(false)
getDataInput(listOf(true))
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `exception while writing encrypted value to output stream`() {
initPlugin(false)
getDataInput(listOf(true))
writeHeaderAndEncrypt()
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptSegment(outputStream, any()) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `exception while flushing output stream`() {
initPlugin(false)
getDataInput(listOf(true))
writeHeaderAndEncrypt()
every { outputStream.write(value) } just Runs
every { outputStream.flush() } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `ignoring exception while closing output stream`() {
initPlugin(false)
getDataInput(listOf(true, false))
writeHeaderAndEncrypt()
every { outputStream.write(value) } just Runs
every { outputStream.flush() } just Runs
every { outputStream.close() } throws IOException()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
private fun singleRecordBackup(hasDataForPackage: Boolean = false) {
initPlugin(hasDataForPackage)
getDataInput(listOf(true, false))
writeHeaderAndEncrypt()
every { outputStream.write(value) } just Runs
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
}
private fun initPlugin(hasDataForPackage: Boolean = false) {
every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage
every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs
}
private fun createBackupDataInput() {
every { inputFactory.getBackupDataInput(data) } returns dataInput
}
private fun getDataInput(returnValues: List<Boolean>) {
createBackupDataInput()
every { dataInput.readNextHeader() } returnsMany returnValues
every { dataInput.key } returns key
every { dataInput.dataSize } returns value.size
every { dataInput.readEntityData(any(), 0, value.size) } returns value.size
}
private fun writeHeaderAndEncrypt() {
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader) } just Runs
every { crypto.encryptSegment(outputStream, any()) } just Runs
}
}

View file

@ -0,0 +1,173 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.*
import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.header.VERSION
import com.stevesoltys.backup.header.VersionHeader
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import kotlin.random.Random
internal class FullRestoreTest : RestoreTest() {
private val plugin = mockk<FullRestorePlugin>()
private val restore = FullRestore(plugin, outputFactory, headerReader, crypto)
private val encrypted = getRandomByteArray()
private val outputStream = ByteArrayOutputStream()
private val versionHeader = VersionHeader(VERSION, packageInfo.packageName)
@Test
fun `has no initial state`() {
assertFalse(restore.hasState())
}
@Test
fun `hasDataForPackage() delegates to plugin`() {
val result = Random.nextBoolean()
every { plugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
}
@Test
fun `initializing state leaves a state`() {
assertFalse(restore.hasState())
restore.initializeState(token, packageInfo)
assertTrue(restore.hasState())
}
@Test
fun `getting chunks without initializing state throws`() {
assertFalse(restore.hasState())
assertThrows(IllegalStateException::class.java) {
restore.getNextFullRestoreDataChunk(fileDescriptor)
}
}
@Test
fun `getting InputStream for package when getting first chunk throws`() {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `reading version header when getting first chunk throws`() {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } throws IOException()
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `reading unsupported version when getting first chunk`() {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `decrypting version header when getting first chunk throws`() {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException()
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `decrypting version header when getting first chunk throws security exception`() {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException()
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `decrypting segment throws IOException`() {
restore.initializeState(token, packageInfo)
initInputStream()
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
every { crypto.decryptSegment(inputStream) } throws IOException()
every { inputStream.close() } just Runs
every { fileDescriptor.close() } just Runs
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `decrypting segment throws EOFException`() {
restore.initializeState(token, packageInfo)
initInputStream()
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
every { crypto.decryptSegment(inputStream) } throws EOFException()
every { inputStream.close() } just Runs
every { fileDescriptor.close() } just Runs
assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `full chunk gets encrypted`() {
restore.initializeState(token, packageInfo)
initInputStream()
readAndEncryptInputStream(encrypted)
every { inputStream.close() } just Runs
assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
assertArrayEquals(encrypted, outputStream.toByteArray())
restore.finishRestore()
assertFalse(restore.hasState())
}
@Test
fun `aborting full restore closes stream, resets state`() {
restore.initializeState(token, packageInfo)
initInputStream()
readAndEncryptInputStream(encrypted)
restore.getNextFullRestoreDataChunk(fileDescriptor)
every { inputStream.close() } just Runs
assertEquals(TRANSPORT_OK, restore.abortFullRestore())
assertFalse(restore.hasState())
}
private fun initInputStream() {
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader
}
private fun readAndEncryptInputStream(encryptedBytes: ByteArray) {
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
every { crypto.decryptSegment(inputStream) } returns encryptedBytes
every { fileDescriptor.close() } just Runs
}
}

View file

@ -0,0 +1,220 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.header.VERSION
import com.stevesoltys.backup.header.VersionHeader
import io.mockk.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
internal class KVRestoreTest : RestoreTest() {
private val plugin = mockk<KVRestorePlugin>()
private val output = mockk<BackupDataOutput>()
private val restore = KVRestore(plugin, outputFactory, headerReader, crypto)
private val key = "Restore Key"
private val key64 = key.encodeBase64()
private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key)
private val key2 = "Restore Key2"
private val key264 = key2.encodeBase64()
private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2)
@Test
fun `hasDataForPackage() delegates to plugin`() {
val result = Random.nextBoolean()
every { plugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
}
@Test
fun `getRestoreData() throws without initializing state`() {
assertThrows(IllegalStateException::class.java) {
restore.getRestoreData(fileDescriptor)
}
}
@Test
fun `listing records throws`() {
restore.initializeState(token, packageInfo)
every { plugin.listRecords(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
}
@Test
fun `reading VersionHeader with unsupported version throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `error reading VersionHeader throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `decrypting segment throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `decrypting header throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `decrypting header throws security exception`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws SecurityException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `writing header throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `writing value throws`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `writing value succeeds`() {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `writing two values succeeds`() {
val data2 = getRandomByteArray()
val inputStream2 = mockk<InputStream>()
restore.initializeState(token, packageInfo)
getRecordsAndOutput(listOf(key64, key264))
// first key/value
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
// second key/value
every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2
every { headerReader.readVersion(inputStream2) } returns VERSION
every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2
every { crypto.decryptSegment(inputStream2) } returns data2
every { output.writeEntityHeader(key2, data2.size) } returns 42
every { output.writeEntityData(data2, data2.size) } returns data2.size
every { inputStream2.close() } just Runs
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
}
private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
every { plugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
}
private fun streamsGetClosed() {
every { inputStream.close() } just Runs
every { fileDescriptor.close() } just Runs
}
private fun verifyStreamWasClosed() {
verifyAll {
inputStream.close()
fileDescriptor.close()
}
}
}

View file

@ -0,0 +1,189 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.transport.TransportTest
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.random.Random
internal class RestoreCoordinatorTest : TransportTest() {
private val plugin = mockk<RestorePlugin>()
private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>()
private val restore = RestoreCoordinator(plugin, kv, full)
private val token = Random.nextLong()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@Test
fun `getAvailableRestoreSets() delegates to plugin`() {
val restoreSets = Array(1) { RestoreSet() }
every { plugin.getAvailableRestoreSets() } returns restoreSets
assertEquals(restoreSets, restore.getAvailableRestoreSets())
}
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
val currentRestoreSet = Random.nextLong()
every { plugin.getCurrentRestoreSet() } returns currentRestoreSet
assertEquals(currentRestoreSet, restore.getCurrentRestoreSet())
}
@Test
fun `startRestore() returns OK`() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray))
}
@Test
fun `startRestore() can not be called twice`() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray))
assertThrows(IllegalStateException::class.javaObjectType) {
restore.startRestore(token, packageInfoArray)
}
}
@Test
fun `nextRestorePackage() throws without startRestore()`() {
assertThrows(IllegalStateException::class.javaObjectType) {
restore.nextRestorePackage()
}
}
@Test
fun `nextRestorePackage() returns KV description and takes precedence`() {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackage() returns full description if no KV data found`() {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns true
every { full.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM)
assertEquals(expected, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns false
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackage() returns all packages from startRestore()`() {
restore.startRestore(token, packageInfoArray2)
every { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
every { kv.hasDataForPackage(token, packageInfo2) } returns false
every { full.hasDataForPackage(token, packageInfo2) } returns true
every { full.initializeState(token, packageInfo2) } just Runs
val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
assertEquals(expected2, restore.nextRestorePackage())
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
}
@Test
fun `when kv#hasDataForPackage() throws return null`() {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage())
}
@Test
fun `when full#hasDataForPackage() throws return null`() {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage())
}
@Test
fun `getRestoreData() delegates to KV`() {
val data = mockk<ParcelFileDescriptor>()
val result = Random.nextInt()
every { kv.getRestoreData(data) } returns result
assertEquals(result, restore.getRestoreData(data))
}
@Test
fun `getNextFullRestoreDataChunk() delegates to Full`() {
val data = mockk<ParcelFileDescriptor>()
val result = Random.nextInt()
every { full.getNextFullRestoreDataChunk(data) } returns result
assertEquals(result, restore.getNextFullRestoreDataChunk(data))
}
@Test
fun `abortFullRestore() delegates to Full`() {
val result = Random.nextInt()
every { full.abortFullRestore() } returns result
assertEquals(result, restore.abortFullRestore())
}
@Test
fun `finishRestore() delegates to Full if it has state`() {
val hasState = Random.nextBoolean()
every { full.hasState() } returns hasState
if (hasState) {
every { full.finishRestore() } just Runs
}
restore.finishRestore()
}
private fun assertEquals(expected: RestoreDescription, actual: RestoreDescription?) {
assertNotNull(actual)
assertEquals(expected.packageName, actual?.packageName)
assertEquals(expected.dataType, actual?.dataType)
}
}

View file

@ -0,0 +1,24 @@
package com.stevesoltys.backup.transport.restore
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.transport.TransportTest
import com.stevesoltys.backup.header.HeaderReader
import com.stevesoltys.backup.header.VERSION
import io.mockk.mockk
import java.io.InputStream
import kotlin.random.Random
internal abstract class RestoreTest : TransportTest() {
protected val outputFactory = mockk<OutputFactory>()
protected val headerReader = mockk<HeaderReader>()
protected val fileDescriptor = mockk<ParcelFileDescriptor>()
protected val token = Random.nextLong()
protected val data = getRandomByteArray()
protected val inputStream = mockk<InputStream>()
protected val unsupportedVersion = (VERSION + 1).toByte()
}

View file

@ -2,7 +2,7 @@
buildscript {
ext.kotlin_version = '1.3.41'
ext.kotlin_version = '1.3.50'
repositories {
jcenter()
@ -23,6 +23,7 @@ allprojects {
mavenCentral()
jcenter()
google()
maven { url 'https://jitpack.io' }
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<exceptions>
<exception package="com.stevesoltys.backup">
<permission name="android.permission.READ_PHONE_STATE" fixed="true"/>
</exception>
</exceptions>