Merge pull request #35 from grote/transport

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

View file

@ -1,3 +1,4 @@
dist: trusty
language: android 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/

View file

@ -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

View file

@ -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.

View file

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

View file

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

View file

@ -14,7 +14,9 @@
android:name="android.permission.BACKUP" 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>

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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);
} }

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup.security package com.stevesoltys.backup.crypto
import android.os.Build.VERSION.SDK_INT import android.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()
} }

View file

@ -0,0 +1,39 @@
package com.stevesoltys.backup.header
internal const val VERSION: Byte = 0
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
internal const val MAX_VERSION_HEADER_SIZE = 1 + Short.SIZE_BYTES * 2 + MAX_PACKAGE_LENGTH_SIZE + MAX_KEY_LENGTH_SIZE
/**
* After the first version byte of each backup stream
* must follow followed this header encrypted with authentication.
*/
data class VersionHeader(
internal val version: Byte = VERSION, // 1 byte
internal val packageName: String, // ?? bytes (max 255)
internal val key: String? = null // ?? bytes
) {
init {
check(packageName.length <= MAX_PACKAGE_LENGTH_SIZE)
key?.let { check(key.length <= MAX_KEY_LENGTH_SIZE) }
}
}
internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES
internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt()
internal const val IV_SIZE: Int = 12
internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE
/**
* Each data segment must start with this header
*/
class SegmentHeader(
internal val segmentLength: Short, // 2 bytes
internal val nonce: ByteArray // 12 bytes
) {
init {
check(nonce.size == IV_SIZE)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,8 +12,6 @@ import com.stevesoltys.backup.session.backup.BackupResult;
import com.stevesoltys.backup.session.backup.BackupSession; import com.stevesoltys.backup.session.backup.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();

View file

@ -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());

View file

@ -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 {

View file

@ -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)
} }

View file

@ -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()
} }
} }

View file

@ -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)

View file

@ -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)
}

View file

@ -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()
} }

View file

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

View file

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

View file

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

View file

@ -1,13 +1,14 @@
package com.stevesoltys.backup.service.backup package com.stevesoltys.backup.transport
import android.app.Service
import android.app.backup.BackupManager import android.app.backup.BackupManager
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

View file

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

View file

@ -0,0 +1,147 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.BackupNotificationManager
import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName
/**
* @author Steve Soltys
* @author Torsten Grote
*/
class BackupCoordinator(
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
private var calledClearBackupData = false
// ------------------------------------------------------------------------------------
// Transport initialization and quota
//
/**
* Initialize the storage for this device, erasing all stored data.
* The transport may send the request immediately, or may buffer it.
* After this is called,
* [finishBackup] will be called to ensure the request is sent and received successfully.
*
* If the transport returns anything other than [TRANSPORT_OK] from this method,
* the OS will halt the current initialize operation and schedule a retry in the near future.
* Even if the transport is in a state
* such that attempting to "initialize" the backend storage is meaningless -
* for example, if there is no current live data-set at all,
* or there is no authenticated account under which to store the data remotely -
* the transport should return [TRANSPORT_OK] here
* and treat the initializeDevice() / finishBackup() pair as a graceful no-op.
*
* @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure).
*/
fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
nm.onBackupError()
TRANSPORT_ERROR
}
}
fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean {
// We need to exclude the DocumentsProvider used to store backup data.
// Otherwise, it gets killed when we back it up, terminating our backup.
return targetPackage.packageName != plugin.providerPackageName
}
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
Log.i(TAG, "Reported quota of $quota bytes.")
return quota
}
// ------------------------------------------------------------------------------------
// Key/value incremental backup support
//
fun requestBackupTime() = kv.requestBackupTime()
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) =
kv.performBackup(packageInfo, data, flags)
// ------------------------------------------------------------------------------------
// Full backup
//
fun requestFullBackupTime() = full.requestFullBackupTime()
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) =
full.performFullBackup(targetPackage, fileDescriptor, flags)
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
fun cancelFullBackup() = full.cancelFullBackup()
// Clear and Finish
/**
* Erase the given application's data from the backup destination.
* This clears out the given package's data from the current backup set,
* making it as though the app had never yet been backed up.
* After this is called, [finishBackup] must be called
* to ensure that the operation is recorded successfully.
*
* @return the same error codes as [performFullBackup].
*/
fun clearBackupData(packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName
Log.i(TAG, "Clear Backup Data of $packageName.")
try {
kv.clearBackupData(packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
return TRANSPORT_ERROR
}
try {
full.clearBackupData(packageInfo)
} catch (e: IOException) {
Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR
}
calledClearBackupData = true
return TRANSPORT_OK
}
fun finishBackup(): Int = when {
kv.hasState() -> {
if (full.hasState()) throw IllegalStateException()
kv.finishBackup()
}
full.hasState() -> {
if (kv.hasState()) throw IllegalStateException()
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
calledInitialize = false
calledClearBackupData = false
TRANSPORT_OK
}
else -> throw IllegalStateException()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
package com.stevesoltys.backup.transport.backup.plugins
import android.content.pm.PackageManager
import com.stevesoltys.backup.transport.backup.BackupPlugin
import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException
class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin {
override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage)
}
@Throws(IOException::class)
override fun initializeDevice() {
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
// create backup folders
val kvDir = storage.defaultKvBackupDir
val fullDir = storage.defaultFullBackupDir
// wipe existing data
kvDir?.deleteContents()
fullDir?.deleteContents()
}
override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
providerInfo.packageName
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@
<!-- Settings --> <!-- 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>

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
buildscript { 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' }
} }
} }

View file

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