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
|
language: android
|
||||||
android:
|
android:
|
||||||
components:
|
components:
|
||||||
|
@ -19,6 +20,8 @@ before_cache:
|
||||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||||
|
|
||||||
|
script: ./gradlew check
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
directories:
|
directories:
|
||||||
- $HOME/.gradle/caches/
|
- $HOME/.gradle/caches/
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
LOCAL_PATH := $(call my-dir)
|
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)
|
include $(CLEAR_VARS)
|
||||||
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
|
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
|
||||||
LOCAL_MODULE_CLASS := ETC
|
LOCAL_MODULE_CLASS := ETC
|
||||||
|
|
|
@ -12,6 +12,7 @@ android {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 26
|
minSdkVersion 26
|
||||||
targetSdkVersion 28
|
targetSdkVersion 28
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
@ -27,6 +28,23 @@ android {
|
||||||
targetCompatibility 1.8
|
targetCompatibility 1.8
|
||||||
sourceCompatibility 1.8
|
sourceCompatibility 1.8
|
||||||
}
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
test {
|
||||||
|
java.srcDirs += "$projectDir/src/sharedTest/java"
|
||||||
|
}
|
||||||
|
androidTest {
|
||||||
|
java.srcDirs += "$projectDir/src/sharedTest/java"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// optional signingConfigs
|
// optional signingConfigs
|
||||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
@ -43,6 +61,7 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes.release.signingConfig = signingConfigs.release
|
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
|
// To produce these binaries, in latest AOSP source tree, run
|
||||||
// $ make
|
// $ make
|
||||||
compileOnly fileTree(include: [
|
def aospDeps = fileTree(include: [
|
||||||
// out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
|
// out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
|
||||||
'android.jar',
|
'android.jar',
|
||||||
// out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
|
// out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
|
||||||
'libcore.jar'
|
'libcore.jar'
|
||||||
], dir: 'libs')
|
], dir: 'libs')
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly aospDeps
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
|
||||||
implementation 'commons-io:commons-io:2.6'
|
implementation 'commons-io:commons-io:2.6'
|
||||||
|
@ -90,4 +111,14 @@ dependencies {
|
||||||
implementation 'com.google.android.material:material:1.0.0'
|
implementation 'com.google.android.material:material:1.0.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
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"
|
android:name="android.permission.BACKUP"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<!-- This is needed to retrieve the 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
|
<application
|
||||||
android:name=".Backup"
|
android:name=".Backup"
|
||||||
|
@ -46,17 +48,12 @@
|
||||||
android:parentActivityName="com.stevesoltys.backup.activity.MainActivity" />
|
android:parentActivityName="com.stevesoltys.backup.activity.MainActivity" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="com.stevesoltys.backup.transport.ConfigurableBackupTransportService"
|
android:name=".transport.ConfigurableBackupTransportService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.backup.TRANSPORT_HOST" />
|
<action android:name="android.backup.TRANSPORT_HOST" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".service.backup.BackupJobService"
|
|
||||||
android:exported="false"
|
|
||||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,11 +1,23 @@
|
||||||
package com.stevesoltys.backup
|
package com.stevesoltys.backup
|
||||||
|
|
||||||
|
import android.Manifest.permission.READ_PHONE_STATE
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.backup.IBackupManager
|
import android.app.backup.IBackupManager
|
||||||
import android.content.Context.BACKUP_SERVICE
|
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.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
|
* @author Steve Soltys
|
||||||
|
@ -17,6 +29,36 @@ class Backup : Application() {
|
||||||
val backupManager: IBackupManager by lazy {
|
val backupManager: IBackupManager by lazy {
|
||||||
IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE))
|
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
|
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.BackupProgress
|
||||||
import android.app.backup.IBackupObserver
|
import android.app.backup.IBackupObserver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Log.INFO
|
import android.util.Log.INFO
|
||||||
import android.util.Log.isLoggable
|
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 val TAG = NotificationBackupObserver::class.java.simpleName
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
|
|
||||||
private val TAG = NotificationBackupObserver::class.java.name
|
class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub() {
|
||||||
|
|
||||||
class NotificationBackupObserver(
|
|
||||||
private val context: Context,
|
|
||||||
private val userInitiated: Boolean) : IBackupObserver.Stub() {
|
|
||||||
|
|
||||||
private val pm = context.packageManager
|
private val pm = context.packageManager
|
||||||
private val nm = context.getSystemService(NotificationManager::class.java).apply {
|
private val nm = (context.applicationContext as Backup).notificationManager
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method could be called several times for packages with full data backup.
|
* 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.
|
* @param backupProgress Current progress of backup for the package.
|
||||||
*/
|
*/
|
||||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||||
val transferred = backupProgress.bytesTransferred
|
val transferred = backupProgress.bytesTransferred.toInt()
|
||||||
val expected = backupProgress.bytesExpected
|
val expected = backupProgress.bytesExpected.toInt()
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
|
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
|
||||||
}
|
}
|
||||||
val notification = notificationBuilder.apply {
|
val app = getAppName(currentBackupPackage)
|
||||||
setContentTitle(context.getString(R.string.notification_title))
|
nm.onBackupUpdate(app, transferred, expected, userInitiated)
|
||||||
setContentText(getAppName(currentBackupPackage))
|
|
||||||
setProgress(expected.toInt(), transferred.toInt(), false)
|
|
||||||
}.build()
|
|
||||||
nm.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,15 +45,7 @@ class NotificationBackupObserver(
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Completed. Target: $target, status: $status")
|
Log.i(TAG, "Completed. Target: $target, status: $status")
|
||||||
}
|
}
|
||||||
val title = context.getString(
|
nm.onBackupResult(getAppName(target), status, userInitiated)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,11 +59,11 @@ class NotificationBackupObserver(
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Backup finished. Status: $status")
|
Log.i(TAG, "Backup finished. Status: $status")
|
||||||
}
|
}
|
||||||
if (status == BackupManager.SUCCESS) getBackupTransport(context).backupFinished()
|
nm.onBackupFinished()
|
||||||
nm.cancel(NOTIFICATION_ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppName(packageId: String): CharSequence {
|
private fun getAppName(packageId: String): CharSequence {
|
||||||
|
if (packageId == "@pm@") return packageId
|
||||||
val appInfo = pm.getApplicationInfo(packageId, 0)
|
val appInfo = pm.getApplicationInfo(packageId, 0)
|
||||||
return pm.getApplicationLabel(appInfo)
|
return pm.getApplicationLabel(appInfo)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import com.stevesoltys.backup.R;
|
||||||
|
|
||||||
import static android.view.View.GONE;
|
import static android.view.View.GONE;
|
||||||
import static android.view.View.VISIBLE;
|
import static android.view.View.VISIBLE;
|
||||||
import static com.stevesoltys.backup.settings.SettingsManagerKt.areBackupsScheduled;
|
|
||||||
|
|
||||||
public class MainActivity extends Activity implements View.OnClickListener {
|
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 = findViewById(R.id.automatic_backups_button);
|
||||||
automaticBackupsButton.setOnClickListener(this);
|
automaticBackupsButton.setOnClickListener(this);
|
||||||
if (areBackupsScheduled(this)) automaticBackupsButton.setVisibility(GONE);
|
|
||||||
|
|
||||||
changeLocationButton = findViewById(R.id.change_backup_location_button);
|
changeLocationButton = findViewById(R.id.change_backup_location_button);
|
||||||
changeLocationButton.setOnClickListener(this);
|
changeLocationButton.setOnClickListener(this);
|
||||||
|
|
|
@ -1,35 +1,26 @@
|
||||||
package com.stevesoltys.backup.activity;
|
package com.stevesoltys.backup.activity;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.job.JobInfo;
|
|
||||||
import android.app.job.JobScheduler;
|
|
||||||
import android.content.ActivityNotFoundException;
|
import android.content.ActivityNotFoundException;
|
||||||
import android.content.ComponentName;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import com.stevesoltys.backup.activity.backup.CreateBackupActivity;
|
import com.stevesoltys.backup.activity.backup.CreateBackupActivity;
|
||||||
import com.stevesoltys.backup.activity.restore.RestoreBackupActivity;
|
import com.stevesoltys.backup.activity.restore.RestoreBackupActivity;
|
||||||
import com.stevesoltys.backup.service.backup.BackupJobService;
|
|
||||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService;
|
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;
|
||||||
import static android.content.Intent.ACTION_OPEN_DOCUMENT_TREE;
|
import static android.content.Intent.ACTION_OPEN_DOCUMENT_TREE;
|
||||||
import static android.content.Intent.CATEGORY_OPENABLE;
|
import static android.content.Intent.CATEGORY_OPENABLE;
|
||||||
import static android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION;
|
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_READ_URI_PERMISSION;
|
||||||
import static android.content.Intent.FLAG_GRANT_WRITE_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_BACKUP_REQUEST_CODE;
|
||||||
import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_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.getBackupFolderUri;
|
||||||
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
|
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
|
||||||
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupFolderUri;
|
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
|
* @author Steve Soltys
|
||||||
|
@ -89,24 +80,8 @@ public class MainActivityController {
|
||||||
return false;
|
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
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
|
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
|
||||||
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupPassword;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
|
@ -116,7 +115,6 @@ class CreateBackupActivityController {
|
||||||
String password = passwordTextView.getText().toString();
|
String password = passwordTextView.getText().toString();
|
||||||
|
|
||||||
if (originalPassword.equals(password)) {
|
if (originalPassword.equals(password)) {
|
||||||
setBackupPassword(parent, password);
|
|
||||||
backupService.backupPackageData(selectedPackages, parent);
|
backupService.backupPackageData(selectedPackages, parent);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -29,7 +29,8 @@ import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
import libcore.io.IoUtils;
|
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
|
* @author Steve Soltys
|
||||||
|
@ -84,7 +85,7 @@ class RestoreBackupActivityController {
|
||||||
while ((zipEntry = inputStream.getNextEntry()) != null) {
|
while ((zipEntry = inputStream.getNextEntry()) != null) {
|
||||||
String zipEntryPath = zipEntry.getName();
|
String zipEntryPath = zipEntry.getName();
|
||||||
|
|
||||||
if (zipEntryPath.startsWith(DEFAULT_FULL_BACKUP_DIRECTORY)) {
|
if (zipEntryPath.startsWith(DIRECTORY_FULL_BACKUP)) {
|
||||||
String fileName = new File(zipEntryPath).getName();
|
String fileName = new File(zipEntryPath).getName();
|
||||||
results.add(fileName);
|
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.os.Build.VERSION.SDK_INT
|
||||||
import android.security.keystore.KeyProperties.*
|
import android.security.keystore.KeyProperties.*
|
||||||
|
@ -8,11 +8,34 @@ import java.security.KeyStore.SecretKeyEntry
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.SecretKeySpec
|
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 KEY_ALIAS = "com.stevesoltys.backup"
|
||||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
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 {
|
private val keyStore by lazy {
|
||||||
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
|
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
|
||||||
|
@ -20,18 +43,18 @@ object KeyManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun storeBackupKey(seed: ByteArray) {
|
override fun storeBackupKey(seed: ByteArray) {
|
||||||
if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException()
|
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
|
||||||
// TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe!
|
// 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)
|
val ksEntry = SecretKeyEntry(secretKeySpec)
|
||||||
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
|
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)
|
keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
|
||||||
|
|
||||||
fun getBackupKey(): SecretKey {
|
override fun getBackupKey(): SecretKey {
|
||||||
val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
|
val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
|
||||||
return ksEntry.secretKey
|
return ksEntry.secretKey
|
||||||
}
|
}
|
||||||
|
@ -41,6 +64,7 @@ object KeyManager {
|
||||||
.setBlockModes(BLOCK_MODE_GCM)
|
.setBlockModes(BLOCK_MODE_GCM)
|
||||||
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
|
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
|
||||||
.setRandomizedEncryptionRequired(true)
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
// unlocking is required only for decryption, so when restoring from backup
|
||||||
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
|
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
|
||||||
return builder.build()
|
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.BackupSession;
|
||||||
import com.stevesoltys.backup.session.backup.BackupSessionObserver;
|
import com.stevesoltys.backup.session.backup.BackupSessionObserver;
|
||||||
|
|
||||||
import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
*/
|
*/
|
||||||
|
@ -61,9 +59,6 @@ class BackupObserver implements BackupSessionObserver {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) {
|
public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) {
|
||||||
|
|
||||||
if (backupResult == BackupResult.SUCCESS) getBackupTransport(context).backupFinished();
|
|
||||||
|
|
||||||
context.runOnUiThread(() -> {
|
context.runOnUiThread(() -> {
|
||||||
if (backupResult == BackupResult.SUCCESS) {
|
if (backupResult == BackupResult.SUCCESS) {
|
||||||
Toast.makeText(context, R.string.backup_success, Toast.LENGTH_LONG).show();
|
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.activity.restore.RestorePopupWindowListener;
|
||||||
import com.stevesoltys.backup.service.TransportService;
|
import com.stevesoltys.backup.service.TransportService;
|
||||||
import com.stevesoltys.backup.session.restore.RestoreSession;
|
import com.stevesoltys.backup.session.restore.RestoreSession;
|
||||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransport;
|
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
*/
|
*/
|
||||||
|
@ -28,8 +25,6 @@ public class RestoreService {
|
||||||
private final TransportService transportService = new TransportService();
|
private final TransportService transportService = new TransportService();
|
||||||
|
|
||||||
public void restorePackages(Set<String> selectedPackages, Uri contentUri, Activity parent, String password) {
|
public void restorePackages(Set<String> selectedPackages, Uri contentUri, Activity parent, String password) {
|
||||||
ConfigurableBackupTransport backupTransport = getBackupTransport(parent.getApplication());
|
|
||||||
backupTransport.prepareRestore(password, contentUri);
|
|
||||||
try {
|
try {
|
||||||
PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent);
|
PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent);
|
||||||
RestoreObserver restoreObserver = new RestoreObserver(parent, popupWindow, selectedPackages.size());
|
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.content.Intent.*
|
import android.content.Intent.*
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract.EXTRA_PROMPT
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.widget.Toast.LENGTH_LONG
|
import android.widget.Toast.LENGTH_LONG
|
||||||
import androidx.lifecycle.ViewModelProviders
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
@ -38,6 +39,7 @@ class BackupLocationFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private fun showChooseFolderActivity() {
|
private fun showChooseFolderActivity() {
|
||||||
val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE)
|
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
|
openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||||
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
|
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
package com.stevesoltys.backup.settings
|
package com.stevesoltys.backup.settings
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.ByteStringUtils.toHexString
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import com.stevesoltys.backup.Backup
|
||||||
import com.stevesoltys.backup.LiveEvent
|
import com.stevesoltys.backup.LiveEvent
|
||||||
import com.stevesoltys.backup.MutableLiveEvent
|
import com.stevesoltys.backup.MutableLiveEvent
|
||||||
import com.stevesoltys.backup.security.KeyManager
|
|
||||||
import io.github.novacrypto.bip39.*
|
import io.github.novacrypto.bip39.*
|
||||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||||
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
|
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
|
||||||
|
@ -21,7 +20,6 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
|
||||||
|
|
||||||
internal val wordList: List<CharSequence> by lazy {
|
internal val wordList: List<CharSequence> by lazy {
|
||||||
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
|
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
|
||||||
// TODO factor out entropy generation
|
|
||||||
val entropy = ByteArray(Words.TWELVE.byteLength())
|
val entropy = ByteArray(Words.TWELVE.byteLength())
|
||||||
SecureRandom().nextBytes(entropy)
|
SecureRandom().nextBytes(entropy)
|
||||||
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
|
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
|
||||||
|
@ -48,10 +46,7 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
|
||||||
}
|
}
|
||||||
val mnemonic = input.joinToString(" ")
|
val mnemonic = input.joinToString(" ")
|
||||||
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
||||||
KeyManager.storeBackupKey(seed)
|
Backup.keyManager.storeBackupKey(seed)
|
||||||
|
|
||||||
// TODO remove once encryption/decryption uses key from KeyStore
|
|
||||||
setBackupPassword(getApplication(), toHexString(seed))
|
|
||||||
|
|
||||||
mRecoveryCodeSaved.setEvent(true)
|
mRecoveryCodeSaved.setEvent(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.ViewModelProviders
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import com.stevesoltys.backup.Backup
|
||||||
import com.stevesoltys.backup.LiveEventHandler
|
import com.stevesoltys.backup.LiveEventHandler
|
||||||
import com.stevesoltys.backup.R
|
import com.stevesoltys.backup.R
|
||||||
|
|
||||||
|
@ -25,8 +26,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
setContentView(R.layout.activity_settings)
|
setContentView(R.layout.activity_settings)
|
||||||
|
|
||||||
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
||||||
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore ->
|
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
|
||||||
if (wasEmptyBefore) showFragment(SettingsFragment())
|
if (initialSetUp) showFragment(SettingsFragment())
|
||||||
else supportFragmentManager.popBackStack()
|
else supportFragmentManager.popBackStack()
|
||||||
})
|
})
|
||||||
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
|
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
|
||||||
|
@ -54,8 +55,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
// check that backup is provisioned
|
// check that backup is provisioned
|
||||||
if (!viewModel.recoveryCodeIsSet()) {
|
if (!viewModel.recoveryCodeIsSet()) {
|
||||||
showRecoveryCodeActivity()
|
showRecoveryCodeActivity()
|
||||||
} else if (!viewModel.locationIsSet()) {
|
} else if (!viewModel.validLocationIsSet()) {
|
||||||
showFragment(BackupLocationFragment())
|
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.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import android.widget.Toast.LENGTH_SHORT
|
||||||
import androidx.lifecycle.ViewModelProviders
|
import androidx.lifecycle.ViewModelProviders
|
||||||
import androidx.preference.Preference.OnPreferenceChangeListener
|
import androidx.preference.Preference.OnPreferenceChangeListener
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
@ -99,7 +100,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
item.itemId == R.id.action_restore -> {
|
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
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
|
|
@ -5,8 +5,8 @@ import android.net.Uri
|
||||||
import android.preference.PreferenceManager.getDefaultSharedPreferences
|
import android.preference.PreferenceManager.getDefaultSharedPreferences
|
||||||
|
|
||||||
private const val PREF_KEY_BACKUP_URI = "backupUri"
|
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_BACKUP_PASSWORD = "backupLegacyPassword"
|
||||||
private const val PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled"
|
|
||||||
|
|
||||||
fun setBackupFolderUri(context: Context, uri: Uri) {
|
fun setBackupFolderUri(context: Context, uri: Uri) {
|
||||||
getDefaultSharedPreferences(context)
|
getDefaultSharedPreferences(context)
|
||||||
|
@ -21,30 +21,18 @@ fun getBackupFolderUri(context: Context): Uri? {
|
||||||
return Uri.parse(uriStr)
|
return Uri.parse(uriStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun setDeviceName(context: Context, name: String) {
|
||||||
* 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) {
|
|
||||||
getDefaultSharedPreferences(context)
|
getDefaultSharedPreferences(context)
|
||||||
.edit()
|
.edit()
|
||||||
.putString(PREF_KEY_BACKUP_PASSWORD, password)
|
.putString(PREF_KEY_DEVICE_NAME, name)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getDeviceName(context: Context): String? {
|
||||||
|
return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null)
|
||||||
|
}
|
||||||
|
|
||||||
@Deprecated("Replaced by KeyManager#getBackupKey()")
|
@Deprecated("Replaced by KeyManager#getBackupKey()")
|
||||||
fun getBackupPassword(context: Context): String? {
|
fun getBackupPassword(context: Context): String? {
|
||||||
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
|
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
|
||||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
import android.content.Intent.FLAG_GRANT_WRITE_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 androidx.lifecycle.AndroidViewModel
|
||||||
|
import com.stevesoltys.backup.Backup
|
||||||
import com.stevesoltys.backup.LiveEvent
|
import com.stevesoltys.backup.LiveEvent
|
||||||
import com.stevesoltys.backup.MutableLiveEvent
|
import com.stevesoltys.backup.MutableLiveEvent
|
||||||
import com.stevesoltys.backup.security.KeyManager
|
import com.stevesoltys.backup.isOnExternalStorage
|
||||||
import com.stevesoltys.backup.service.backup.requestFullBackup
|
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) {
|
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
@ -27,8 +31,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||||
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
|
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
|
||||||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||||
|
|
||||||
fun recoveryCodeIsSet() = KeyManager.hasBackupKey()
|
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
|
||||||
fun locationIsSet() = getBackupFolderUri(getApplication()) != null
|
|
||||||
|
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?) {
|
fun handleChooseFolderResult(result: Intent?) {
|
||||||
val folderUri = result?.data ?: return
|
val folderUri = result?.data ?: return
|
||||||
|
@ -38,15 +48,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||||
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
|
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
|
||||||
|
|
||||||
// check if this is initial set-up or a later change
|
// 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
|
// store backup folder location in settings
|
||||||
setBackupFolderUri(app, folderUri)
|
setBackupFolderUri(app, folderUri)
|
||||||
|
|
||||||
// notify the UI that the location has been set
|
// 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
|
||||||
import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP
|
import android.app.backup.BackupTransport
|
||||||
import android.app.backup.BackupTransport.FLAG_USER_INITIATED
|
|
||||||
import android.app.job.JobParameters
|
|
||||||
import android.app.job.JobService
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.BACKUP_SERVICE
|
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.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
@ -15,36 +16,40 @@ import com.stevesoltys.backup.Backup
|
||||||
import com.stevesoltys.backup.NotificationBackupObserver
|
import com.stevesoltys.backup.NotificationBackupObserver
|
||||||
import com.stevesoltys.backup.service.PackageService
|
import com.stevesoltys.backup.service.PackageService
|
||||||
import com.stevesoltys.backup.session.backup.BackupMonitor
|
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 {
|
private var transport: ConfigurableBackupTransport? = null
|
||||||
Log.i(TAG, "Triggering full backup")
|
|
||||||
try {
|
override fun onCreate() {
|
||||||
requestFullBackup(this)
|
super.onCreate()
|
||||||
} finally {
|
transport = ConfigurableBackupTransport(applicationContext)
|
||||||
jobFinished(params, false)
|
Log.d(TAG, "Service created.")
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStopJob(params: JobParameters): Boolean {
|
override fun onBind(intent: Intent): IBinder {
|
||||||
try {
|
val transport = this.transport ?: throw IllegalStateException()
|
||||||
Backup.backupManager.cancelBackups()
|
return transport.binder.apply {
|
||||||
} catch (e: RemoteException) {
|
Log.d(TAG, "Transport bound.")
|
||||||
Log.e(TAG, "Error cancelling backup: ", e)
|
|
||||||
}
|
}
|
||||||
return true
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
transport = null
|
||||||
|
Log.d(TAG, "Service destroyed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun requestFullBackup(context: Context) {
|
fun requestBackup(context: Context) {
|
||||||
context.startService(Intent(context, ConfigurableBackupTransportService::class.java))
|
context.startService(Intent(context, ConfigurableBackupTransportService::class.java))
|
||||||
val observer = NotificationBackupObserver(context, true)
|
val observer = NotificationBackupObserver(context, true)
|
||||||
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
|
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 -->
|
<!-- Settings -->
|
||||||
<string name="settings_backup">Backup my data</string>
|
<string name="settings_backup">Backup my data</string>
|
||||||
<string name="settings_backup_location">Backup location</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_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_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>
|
<string name="settings_backup_external_storage">External Storage</string>
|
||||||
|
@ -61,6 +62,12 @@
|
||||||
<string name="notification_channel_title">Backup Notification</string>
|
<string name="notification_channel_title">Backup Notification</string>
|
||||||
<string name="notification_title">Backup running</string>
|
<string name="notification_title">Backup running</string>
|
||||||
<string name="notification_backup_result_complete">Backup complete</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_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>
|
</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 {
|
buildscript {
|
||||||
|
|
||||||
ext.kotlin_version = '1.3.41'
|
ext.kotlin_version = '1.3.50'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
jcenter()
|
jcenter()
|
||||||
|
@ -23,6 +23,7 @@ allprojects {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
jcenter()
|
jcenter()
|
||||||
google()
|
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