Merge pull request #35 from grote/transport
Huge refactoring of backup transport
This commit is contained in:
commit
7a7059cda3
88 changed files with 4803 additions and 1618 deletions
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
18
app/src/main/java/com/stevesoltys/backup/Base64Utils.kt
Normal file
18
app/src/main/java/com/stevesoltys/backup/Base64Utils.kt
Normal 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))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
138
app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt
Normal file
138
app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
39
app/src/main/java/com/stevesoltys/backup/header/Header.kt
Normal file
39
app/src/main/java/com/stevesoltys/backup/header/Header.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
package com.stevesoltys.backup.security;
|
||||
|
||||
import javax.crypto.*;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* A utility class for encrypting and decrypting data using a {@link Cipher}.
|
||||
*
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public class CipherUtil {
|
||||
|
||||
/**
|
||||
* The cipher algorithm.
|
||||
*/
|
||||
public static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
|
||||
|
||||
/**
|
||||
* .
|
||||
* Encrypts the given payload using the provided secret key.
|
||||
*
|
||||
* @param payload The payload.
|
||||
* @param secretKey The secret key.
|
||||
* @param iv The initialization vector.
|
||||
*/
|
||||
public static byte[] encrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
|
||||
NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
|
||||
InvalidAlgorithmParameterException, InvalidKeyException {
|
||||
|
||||
return startEncrypt(secretKey, iv).doFinal(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a cipher in {@link Cipher#ENCRYPT_MODE}.
|
||||
*
|
||||
* @param secretKey The secret key.
|
||||
* @param iv The initialization vector.
|
||||
* @return The initialized cipher.
|
||||
*/
|
||||
public static Cipher startEncrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
|
||||
NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
|
||||
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
|
||||
return cipher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the given payload using the provided secret key.
|
||||
*
|
||||
* @param payload The payload.
|
||||
* @param secretKey The secret key.
|
||||
* @param iv The initialization vector.
|
||||
*/
|
||||
public static byte[] decrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
|
||||
NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
|
||||
InvalidAlgorithmParameterException, InvalidKeyException {
|
||||
|
||||
return startDecrypt(secretKey, iv).doFinal(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a cipher in {@link Cipher#DECRYPT_MODE}.
|
||||
*
|
||||
* @param secretKey The secret key.
|
||||
* @param iv The initialization vector.
|
||||
* @return The initialized cipher.
|
||||
*/
|
||||
public static Cipher startDecrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
|
||||
NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
|
||||
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
|
||||
return cipher;
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
package com.stevesoltys.backup.security;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.KeySpec;
|
||||
|
||||
/**
|
||||
* A utility class which can be used for generating an AES secret key using PBKDF2.
|
||||
*
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public class KeyGenerator {
|
||||
|
||||
/**
|
||||
* The number of iterations for key generation.
|
||||
*/
|
||||
private static final int ITERATIONS = 32767;
|
||||
|
||||
/**
|
||||
* The generated key length.
|
||||
*/
|
||||
private static final int KEY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Generates an AES secret key using PBKDF2.
|
||||
*
|
||||
* @param password The password.
|
||||
* @param salt The salt.
|
||||
* @return The generated key.
|
||||
*/
|
||||
public static SecretKey generate(String password, byte[] salt)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
|
||||
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
|
||||
KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
|
||||
|
||||
SecretKey secretKey = secretKeyFactory.generateSecret(keySpec);
|
||||
return new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
package com.stevesoltys.backup.service;
|
||||
|
||||
import android.app.backup.IBackupManager;
|
||||
import android.content.pm.IPackageManager;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.os.RemoteException;
|
||||
import android.os.ServiceManager;
|
||||
import android.os.UserHandle;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.google.android.collect.Sets.newArraySet;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public class PackageService {
|
||||
|
||||
private final IBackupManager backupManager;
|
||||
|
||||
private final IPackageManager packageManager;
|
||||
|
||||
private static final Set<String> IGNORED_PACKAGES = newArraySet(
|
||||
"com.android.externalstorage",
|
||||
"com.android.providers.downloads.ui",
|
||||
"com.android.providers.downloads",
|
||||
"com.android.providers.media",
|
||||
"com.android.providers.calendar",
|
||||
"com.android.providers.contacts",
|
||||
"com.stevesoltys.backup"
|
||||
);
|
||||
|
||||
public PackageService() {
|
||||
backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup"));
|
||||
packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));
|
||||
}
|
||||
|
||||
public String[] getEligiblePackages() throws RemoteException {
|
||||
List<PackageInfo> packages = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).getList();
|
||||
String[] packageArray = packages.stream()
|
||||
.map(packageInfo -> packageInfo.packageName)
|
||||
.filter(packageName -> !IGNORED_PACKAGES.contains(packageName))
|
||||
.toArray(String[]::new);
|
||||
|
||||
return backupManager.filterAppsEligibleForBackup(packageArray);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package com.stevesoltys.backup.transport;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public class ConfigurableBackupTransportService extends Service {
|
||||
|
||||
private static final String TAG = ConfigurableBackupTransportService.class.getName();
|
||||
|
||||
private static ConfigurableBackupTransport backupTransport = null;
|
||||
|
||||
public static ConfigurableBackupTransport getBackupTransport(Context context) {
|
||||
|
||||
if (backupTransport == null) {
|
||||
backupTransport = new ConfigurableBackupTransport(context);
|
||||
}
|
||||
|
||||
return backupTransport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.d(TAG, "Service created.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return getBackupTransport(getApplicationContext()).getBinder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.d(TAG, "Service destroyed.");
|
||||
}
|
||||
}
|
|
@ -1,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
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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?
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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>()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component;
|
||||
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public interface BackupComponent {
|
||||
|
||||
String currentDestinationString();
|
||||
|
||||
String dataManagementLabel();
|
||||
|
||||
int initializeDevice();
|
||||
|
||||
int clearBackupData(PackageInfo packageInfo);
|
||||
|
||||
int finishBackup();
|
||||
|
||||
int performIncrementalBackup(PackageInfo targetPackage, ParcelFileDescriptor data, int flags);
|
||||
|
||||
int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor);
|
||||
|
||||
int checkFullBackupSize(long size);
|
||||
|
||||
int sendBackupData(int numBytes);
|
||||
|
||||
void cancelFullBackup();
|
||||
|
||||
long getBackupQuota(String packageName, boolean fullBackup);
|
||||
|
||||
long requestBackupTime();
|
||||
|
||||
long requestFullBackupTime();
|
||||
|
||||
void backupFinished();
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component;
|
||||
|
||||
import android.app.backup.RestoreDescription;
|
||||
import android.app.backup.RestoreSet;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public interface RestoreComponent {
|
||||
|
||||
void prepareRestore(String password, Uri fileUri);
|
||||
|
||||
int startRestore(long token, PackageInfo[] packages);
|
||||
|
||||
RestoreDescription nextRestorePackage();
|
||||
|
||||
int getRestoreData(ParcelFileDescriptor outputFileDescriptor);
|
||||
|
||||
int getNextFullRestoreDataChunk(ParcelFileDescriptor socket);
|
||||
|
||||
int abortFullRestore();
|
||||
|
||||
long getCurrentRestoreSet();
|
||||
|
||||
void finishRestore();
|
||||
|
||||
RestoreSet[] getAvailableRestoreSets();
|
||||
}
|
|
@ -1,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;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component.provider;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public interface ContentProviderBackupConstants {
|
||||
|
||||
String SALT_FILE_PATH = "salt";
|
||||
|
||||
String DEFAULT_FULL_BACKUP_DIRECTORY = "full/";
|
||||
|
||||
String DEFAULT_INCREMENTAL_BACKUP_DIRECTORY = "incr/";
|
||||
|
||||
long DEFAULT_BACKUP_QUOTA = Long.MAX_VALUE;
|
||||
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component.provider;
|
||||
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
class ContentProviderBackupState {
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private ParcelFileDescriptor inputFileDescriptor;
|
||||
|
||||
private ParcelFileDescriptor outputFileDescriptor;
|
||||
|
||||
private InputStream inputStream;
|
||||
|
||||
private ZipOutputStream outputStream;
|
||||
|
||||
private Cipher cipher;
|
||||
|
||||
private long bytesTransferred;
|
||||
|
||||
private String packageName;
|
||||
|
||||
private byte[] salt;
|
||||
|
||||
private SecretKey secretKey;
|
||||
|
||||
ContentProviderBackupState() {
|
||||
salt = new byte[16];
|
||||
SECURE_RANDOM.nextBytes(salt);
|
||||
}
|
||||
|
||||
long getBytesTransferred() {
|
||||
return bytesTransferred;
|
||||
}
|
||||
|
||||
void setBytesTransferred(long bytesTransferred) {
|
||||
this.bytesTransferred = bytesTransferred;
|
||||
}
|
||||
|
||||
Cipher getCipher() {
|
||||
return cipher;
|
||||
}
|
||||
|
||||
void setCipher(Cipher cipher) {
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
ParcelFileDescriptor getInputFileDescriptor() {
|
||||
return inputFileDescriptor;
|
||||
}
|
||||
|
||||
void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
|
||||
this.inputFileDescriptor = inputFileDescriptor;
|
||||
}
|
||||
|
||||
InputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
void setInputStream(InputStream inputStream) {
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
ParcelFileDescriptor getOutputFileDescriptor() {
|
||||
return outputFileDescriptor;
|
||||
}
|
||||
|
||||
void setOutputFileDescriptor(ParcelFileDescriptor outputFileDescriptor) {
|
||||
this.outputFileDescriptor = outputFileDescriptor;
|
||||
}
|
||||
|
||||
ZipOutputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
void setOutputStream(ZipOutputStream outputStream) {
|
||||
this.outputStream = outputStream;
|
||||
}
|
||||
|
||||
String getPackageName() {
|
||||
return packageName;
|
||||
}
|
||||
|
||||
void setPackageName(String packageName) {
|
||||
this.packageName = packageName;
|
||||
}
|
||||
|
||||
byte[] getSalt() {
|
||||
return salt;
|
||||
}
|
||||
|
||||
SecretKey getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
void setSecretKey(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
}
|
|
@ -1,360 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component.provider;
|
||||
|
||||
import android.annotation.Nullable;
|
||||
import android.app.backup.BackupDataOutput;
|
||||
import android.app.backup.RestoreDescription;
|
||||
import android.app.backup.RestoreSet;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.internal.util.Preconditions;
|
||||
import com.stevesoltys.backup.security.CipherUtil;
|
||||
import com.stevesoltys.backup.security.KeyGenerator;
|
||||
import com.stevesoltys.backup.transport.component.RestoreComponent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import libcore.io.IoUtils;
|
||||
import libcore.io.Streams;
|
||||
|
||||
import static android.app.backup.BackupTransport.NO_MORE_DATA;
|
||||
import static android.app.backup.BackupTransport.TRANSPORT_ERROR;
|
||||
import static android.app.backup.BackupTransport.TRANSPORT_OK;
|
||||
import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED;
|
||||
import static android.app.backup.RestoreDescription.TYPE_FULL_STREAM;
|
||||
import static android.app.backup.RestoreDescription.TYPE_KEY_VALUE;
|
||||
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
|
||||
import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
public class ContentProviderRestoreComponent implements RestoreComponent {
|
||||
|
||||
private static final String TAG = ContentProviderRestoreComponent.class.getName();
|
||||
|
||||
private static final int DEFAULT_RESTORE_SET = 1;
|
||||
|
||||
private static final int DEFAULT_BUFFER_SIZE = 2048;
|
||||
|
||||
@Nullable
|
||||
private String password;
|
||||
@Nullable
|
||||
private Uri fileUri;
|
||||
|
||||
private ContentProviderRestoreState restoreState;
|
||||
|
||||
private final Context context;
|
||||
|
||||
public ContentProviderRestoreComponent(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareRestore(String password, Uri fileUri) {
|
||||
this.password = password;
|
||||
this.fileUri = fileUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int startRestore(long token, PackageInfo[] packages) {
|
||||
restoreState = new ContentProviderRestoreState();
|
||||
restoreState.setPackages(packages);
|
||||
restoreState.setPackageIndex(-1);
|
||||
|
||||
String password = requireNonNull(this.password);
|
||||
|
||||
if (!password.isEmpty()) {
|
||||
try {
|
||||
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
|
||||
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
|
||||
seekToEntry(inputStream, ContentProviderBackupConstants.SALT_FILE_PATH);
|
||||
|
||||
restoreState.setSalt(Streams.readFullyNoClose(inputStream));
|
||||
restoreState.setSecretKey(KeyGenerator.generate(password, restoreState.getSalt()));
|
||||
|
||||
IoUtils.closeQuietly(inputFileDescriptor);
|
||||
IoUtils.closeQuietly(inputStream);
|
||||
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Salt not found", ex);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
List<ZipEntry> zipEntries = new LinkedList<>();
|
||||
|
||||
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
|
||||
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
|
||||
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = inputStream.getNextEntry()) != null) {
|
||||
zipEntries.add(zipEntry);
|
||||
inputStream.closeEntry();
|
||||
}
|
||||
|
||||
IoUtils.closeQuietly(inputFileDescriptor);
|
||||
IoUtils.closeQuietly(inputStream);
|
||||
|
||||
restoreState.setZipEntries(zipEntries);
|
||||
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Error while caching zip entries", ex);
|
||||
}
|
||||
|
||||
return TRANSPORT_OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreDescription nextRestorePackage() {
|
||||
Preconditions.checkNotNull(restoreState, "startRestore() not called");
|
||||
|
||||
int packageIndex = restoreState.getPackageIndex();
|
||||
PackageInfo[] packages = restoreState.getPackages();
|
||||
|
||||
while (++packageIndex < packages.length) {
|
||||
restoreState.setPackageIndex(packageIndex);
|
||||
String name = packages[packageIndex].packageName;
|
||||
|
||||
if (containsPackageFile(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + name)) {
|
||||
restoreState.setRestoreType(TYPE_KEY_VALUE);
|
||||
return new RestoreDescription(name, restoreState.getRestoreType());
|
||||
|
||||
} else if (containsPackageFile(DEFAULT_FULL_BACKUP_DIRECTORY + name)) {
|
||||
restoreState.setRestoreType(TYPE_FULL_STREAM);
|
||||
return new RestoreDescription(name, restoreState.getRestoreType());
|
||||
}
|
||||
}
|
||||
return RestoreDescription.NO_MORE_PACKAGES;
|
||||
}
|
||||
|
||||
private boolean containsPackageFile(String fileName) {
|
||||
return restoreState.getZipEntries().stream()
|
||||
.anyMatch(zipEntry -> zipEntry.getName().startsWith(fileName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
|
||||
Preconditions.checkState(restoreState != null, "startRestore() not called");
|
||||
Preconditions.checkState(restoreState.getPackageIndex() >= 0, "nextRestorePackage() not called");
|
||||
Preconditions.checkState(restoreState.getRestoreType() == TYPE_KEY_VALUE,
|
||||
"getRestoreData() for non-key/value dataset");
|
||||
|
||||
PackageInfo packageInfo = restoreState.getPackages()[restoreState.getPackageIndex()];
|
||||
|
||||
try {
|
||||
return transferIncrementalRestoreData(packageInfo.packageName, outputFileDescriptor);
|
||||
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Unable to read backup records: ", ex);
|
||||
return TRANSPORT_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
private int transferIncrementalRestoreData(String packageName, ParcelFileDescriptor outputFileDescriptor)
|
||||
throws Exception {
|
||||
|
||||
ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
|
||||
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
|
||||
BackupDataOutput backupDataOutput = new BackupDataOutput(outputFileDescriptor.getFileDescriptor());
|
||||
|
||||
Optional<ZipEntry> zipEntryOptional = seekToEntry(inputStream,
|
||||
DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
|
||||
|
||||
while (zipEntryOptional.isPresent()) {
|
||||
String fileName = new File(zipEntryOptional.get().getName()).getName();
|
||||
String blobKey = new String(Base64.decode(fileName, Base64.DEFAULT));
|
||||
|
||||
byte[] backupData = readBackupData(inputStream);
|
||||
backupDataOutput.writeEntityHeader(blobKey, backupData.length);
|
||||
backupDataOutput.writeEntityData(backupData, backupData.length);
|
||||
inputStream.closeEntry();
|
||||
|
||||
zipEntryOptional = seekToEntry(inputStream, DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
|
||||
}
|
||||
|
||||
IoUtils.closeQuietly(inputFileDescriptor);
|
||||
IoUtils.closeQuietly(outputFileDescriptor);
|
||||
return TRANSPORT_OK;
|
||||
}
|
||||
|
||||
private byte[] readBackupData(ZipInputStream inputStream) throws Exception {
|
||||
byte[] backupData = Streams.readFullyNoClose(inputStream);
|
||||
SecretKey secretKey = restoreState.getSecretKey();
|
||||
byte[] initializationVector = restoreState.getSalt();
|
||||
|
||||
if (secretKey != null) {
|
||||
backupData = CipherUtil.decrypt(backupData, secretKey, initializationVector);
|
||||
}
|
||||
|
||||
return backupData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getNextFullRestoreDataChunk(ParcelFileDescriptor outputFileDescriptor) {
|
||||
Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM,
|
||||
"Asked for full restore data for non-stream package");
|
||||
|
||||
ParcelFileDescriptor inputFileDescriptor = restoreState.getInputFileDescriptor();
|
||||
|
||||
if (inputFileDescriptor == null) {
|
||||
String name = restoreState.getPackages()[restoreState.getPackageIndex()].packageName;
|
||||
|
||||
try {
|
||||
inputFileDescriptor = buildInputFileDescriptor();
|
||||
restoreState.setInputFileDescriptor(inputFileDescriptor);
|
||||
|
||||
ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
|
||||
restoreState.setInputStream(inputStream);
|
||||
|
||||
if (!seekToEntry(inputStream, DEFAULT_FULL_BACKUP_DIRECTORY + name).isPresent()) {
|
||||
IoUtils.closeQuietly(inputFileDescriptor);
|
||||
IoUtils.closeQuietly(outputFileDescriptor);
|
||||
return TRANSPORT_PACKAGE_REJECTED;
|
||||
}
|
||||
|
||||
} catch (IOException ex) {
|
||||
Log.e(TAG, "Unable to read archive for " + name, ex);
|
||||
|
||||
IoUtils.closeQuietly(inputFileDescriptor);
|
||||
IoUtils.closeQuietly(outputFileDescriptor);
|
||||
return TRANSPORT_PACKAGE_REJECTED;
|
||||
}
|
||||
}
|
||||
|
||||
return transferFullRestoreData(outputFileDescriptor);
|
||||
}
|
||||
|
||||
private int transferFullRestoreData(ParcelFileDescriptor outputFileDescriptor) {
|
||||
ZipInputStream inputStream = restoreState.getInputStream();
|
||||
OutputStream outputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
|
||||
|
||||
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
|
||||
int bytesRead = NO_MORE_DATA;
|
||||
|
||||
try {
|
||||
bytesRead = inputStream.read(buffer);
|
||||
|
||||
if (bytesRead <= 0) {
|
||||
bytesRead = NO_MORE_DATA;
|
||||
|
||||
if (restoreState.getCipher() != null) {
|
||||
buffer = restoreState.getCipher().doFinal();
|
||||
bytesRead = buffer.length;
|
||||
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
restoreState.setCipher(null);
|
||||
}
|
||||
|
||||
} else {
|
||||
if (restoreState.getSecretKey() != null) {
|
||||
SecretKey secretKey = restoreState.getSecretKey();
|
||||
byte[] salt = restoreState.getSalt();
|
||||
|
||||
if (restoreState.getCipher() == null) {
|
||||
restoreState.setCipher(CipherUtil.startDecrypt(secretKey, salt));
|
||||
}
|
||||
|
||||
buffer = restoreState.getCipher().update(Arrays.copyOfRange(buffer, 0, bytesRead));
|
||||
bytesRead = buffer.length;
|
||||
}
|
||||
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Exception while streaming restore data: ", e);
|
||||
return TRANSPORT_ERROR;
|
||||
|
||||
} finally {
|
||||
if (bytesRead == NO_MORE_DATA) {
|
||||
|
||||
if (restoreState.getInputFileDescriptor() != null) {
|
||||
IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
|
||||
}
|
||||
|
||||
restoreState.setInputFileDescriptor(null);
|
||||
restoreState.setInputStream(null);
|
||||
}
|
||||
|
||||
IoUtils.closeQuietly(outputFileDescriptor);
|
||||
}
|
||||
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int abortFullRestore() {
|
||||
resetFullRestoreState();
|
||||
return TRANSPORT_OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getCurrentRestoreSet() {
|
||||
return DEFAULT_RESTORE_SET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishRestore() {
|
||||
if (restoreState.getRestoreType() == TYPE_FULL_STREAM) {
|
||||
resetFullRestoreState();
|
||||
}
|
||||
|
||||
restoreState = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreSet[] getAvailableRestoreSets() {
|
||||
return new RestoreSet[]{new RestoreSet("Local disk image", "flash", DEFAULT_RESTORE_SET)};
|
||||
}
|
||||
|
||||
private void resetFullRestoreState() {
|
||||
Preconditions.checkNotNull(restoreState);
|
||||
Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM);
|
||||
|
||||
IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
|
||||
restoreState = null;
|
||||
}
|
||||
|
||||
private ParcelFileDescriptor buildInputFileDescriptor() throws FileNotFoundException {
|
||||
ContentResolver contentResolver = context.getContentResolver();
|
||||
return contentResolver.openFileDescriptor(requireNonNull(fileUri), "r");
|
||||
}
|
||||
|
||||
private ZipInputStream buildInputStream(ParcelFileDescriptor inputFileDescriptor) {
|
||||
FileInputStream fileInputStream = new FileInputStream(inputFileDescriptor.getFileDescriptor());
|
||||
return new ZipInputStream(fileInputStream);
|
||||
}
|
||||
|
||||
private Optional<ZipEntry> seekToEntry(ZipInputStream inputStream, String entryPath) throws IOException {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = inputStream.getNextEntry()) != null) {
|
||||
|
||||
if (zipEntry.getName().startsWith(entryPath)) {
|
||||
return Optional.of(zipEntry);
|
||||
}
|
||||
inputStream.closeEntry();
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
package com.stevesoltys.backup.transport.component.provider;
|
||||
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* @author Steve Soltys
|
||||
*/
|
||||
class ContentProviderRestoreState {
|
||||
|
||||
private ParcelFileDescriptor inputFileDescriptor;
|
||||
|
||||
private PackageInfo[] packages;
|
||||
|
||||
private int packageIndex;
|
||||
|
||||
private int restoreType;
|
||||
|
||||
private ZipInputStream inputStream;
|
||||
|
||||
private Cipher cipher;
|
||||
|
||||
private byte[] salt;
|
||||
|
||||
private SecretKey secretKey;
|
||||
|
||||
private List<ZipEntry> zipEntries;
|
||||
|
||||
Cipher getCipher() {
|
||||
return cipher;
|
||||
}
|
||||
|
||||
ParcelFileDescriptor getInputFileDescriptor() {
|
||||
return inputFileDescriptor;
|
||||
}
|
||||
|
||||
void setCipher(Cipher cipher) {
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
|
||||
this.inputFileDescriptor = inputFileDescriptor;
|
||||
}
|
||||
|
||||
ZipInputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
void setInputStream(ZipInputStream inputStream) {
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
int getPackageIndex() {
|
||||
return packageIndex;
|
||||
}
|
||||
|
||||
void setPackageIndex(int packageIndex) {
|
||||
this.packageIndex = packageIndex;
|
||||
}
|
||||
|
||||
PackageInfo[] getPackages() {
|
||||
return packages;
|
||||
}
|
||||
|
||||
void setPackages(PackageInfo[] packages) {
|
||||
this.packages = packages;
|
||||
}
|
||||
|
||||
int getRestoreType() {
|
||||
return restoreType;
|
||||
}
|
||||
|
||||
void setRestoreType(int restoreType) {
|
||||
this.restoreType = restoreType;
|
||||
}
|
||||
|
||||
byte[] getSalt() {
|
||||
return salt;
|
||||
}
|
||||
|
||||
void setSalt(byte[] salt) {
|
||||
this.salt = salt;
|
||||
}
|
||||
|
||||
public SecretKey getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public List<ZipEntry> getZipEntries() {
|
||||
return zipEntries;
|
||||
}
|
||||
|
||||
public void setZipEntries(List<ZipEntry> zipEntries) {
|
||||
this.zipEntries = zipEntries;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
10
app/src/main/res/drawable/ic_cloud_error.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_error.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
36
app/src/sharedTest/java/com/stevesoltys/backup/TestUtils.kt
Normal file
36
app/src/sharedTest/java/com/stevesoltys/backup/TestUtils.kt
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
192
app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt
Normal file
192
app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt
Normal 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
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
6
default-permissions_com.stevesoltys.backup.xml
Normal file
6
default-permissions_com.stevesoltys.backup.xml
Normal 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>
|
Loading…
Reference in a new issue