Merge pull request #75 from stevesoltys/develop

Merge develop into master
This commit is contained in:
Steve Soltys 2020-03-07 15:50:20 -05:00 committed by GitHub
commit a585324c71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 4417 additions and 632 deletions

View file

@ -1,3 +1,16 @@
## [1.0.0] - 2020-03-07
## Added
- APK backup and restore support with the option to toggle them off.
- Note to auto-restore setting in case removable storage is used.
- UX for showing which packages were restored and which failed.
- Show heads-up notification when auto-restore fails due to removed storage.
- Show list of apps and their backup status.
- Support for excluding apps from backups.
## Fixed
- Device initialization and generation of new backup tokens.
## [1.0.0-alpha1] - 2019-12-14
### Added
- Automatic daily backups that run in the background.

View file

@ -14,14 +14,15 @@ A backup application for the [Android Open Source Project](https://source.androi
AOSP.
## What makes this different?
This application is compiled with the operating system and does not require a rooted device for use. It uses the same
internal APIs as `adb backup` and requires a minimal number of permissions to achieve this.
This application is compiled with the operating system and does not require a rooted device for use.
It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement.
## Permissions
* `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.

View file

@ -13,6 +13,7 @@ android {
minSdkVersion 29
targetSdkVersion 29
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true'
}
buildTypes {
@ -28,6 +29,9 @@ android {
targetCompatibility 1.8
sourceCompatibility 1.8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
testOptions {
unitTests.all {
useJUnitPlatform()
@ -35,6 +39,9 @@ android {
events "passed", "skipped", "failed"
}
}
unitTests {
includeAndroidResources = true
}
}
sourceSets {
@ -115,14 +122,20 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-rc03'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
def junit_version = "5.5.2"
testImplementation aospDeps
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
testImplementation 'io.mockk:mockk:1.9.3'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'

View file

@ -3,9 +3,10 @@ package com.stevesoltys.seedvault
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
import com.stevesoltys.seedvault.settings.SettingsManager
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNotNull
@ -22,8 +23,9 @@ private const val filename = "test-file"
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager by inject<MetadataManager>()
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, settingsManager)
private val storage = DocumentsStorage(context, metadataManager, settingsManager)
private lateinit var file: DocumentFile

View file

@ -2,8 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.stevesoltys.seedvault"
android:versionCode="6"
android:versionName="1.0.0-alpha1">
android:versionCode="7"
android:versionName="1.0.0">
<uses-permission
android:name="android.permission.BACKUP"
@ -18,12 +18,21 @@
<uses-permission
android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions" />
g
<!-- This is needed to change system backup settings -->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to re-install backed-up packages when restoring from backup -->
<uses-permission
android:name="android.permission.INSTALL_PACKAGES"
tools:ignore="ProtectedPermissions" />
<!-- This is needed when using auto-restore with removable storage
to allow the user to uninstall an app when storage was not plugged in during install -->
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<application
android:name=".App"
android:allowBackup="false"
@ -82,5 +91,13 @@
android:resource="@xml/device_filter" />
</receiver>
<receiver
android:name=".restore.RestoreErrorBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.stevesoltys.seedvault.action.UNINSTALL" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -9,11 +9,11 @@ import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel
import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.transport.restore.restoreModule
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
@ -33,13 +33,14 @@ class App : Application() {
private val appModule = module {
single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) }
single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
viewModel { SettingsViewModel(this@App, get(), get()) }
viewModel { SettingsViewModel(this@App, get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
}
override fun onCreate() {
@ -62,5 +63,7 @@ class App : Application() {
}
const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL
const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
const val GLOBAL_METADATA_KEY = "@meta@"
fun isDebugBuild() = Build.TYPE == "userdebug"

View file

@ -1,6 +1,8 @@
package com.stevesoltys.seedvault
import android.app.backup.BackupManagerMonitor.*
import android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_CATEGORY
import android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_ID
import android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_PACKAGE_NAME
import android.app.backup.IBackupManagerMonitor
import android.os.Bundle
import android.util.Log

View file

@ -3,25 +3,37 @@ package com.stevesoltys.seedvault
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.PendingIntent
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat.*
import android.content.pm.PackageManager.NameNotFoundException
import androidx.core.app.NotificationCompat.Action
import androidx.core.app.NotificationCompat.Builder
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
import androidx.core.app.NotificationCompat.PRIORITY_LOW
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity
import java.util.*
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
private const val CHANNEL_ID_ERROR = "NotificationError"
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
private const val NOTIFICATION_ID_OBSERVER = 1
private const val NOTIFICATION_ID_ERROR = 2
private const val NOTIFICATION_ID_RESTORE_ERROR = 3
class BackupNotificationManager(private val context: Context) {
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
createNotificationChannel(getObserverChannel())
createNotificationChannel(getErrorChannel())
createNotificationChannel(getRestoreErrorChannel())
}
private fun getObserverChannel(): NotificationChannel {
@ -36,53 +48,64 @@ class BackupNotificationManager(private val context: Context) {
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)
private fun getRestoreErrorChannel(): NotificationChannel {
val title = context.getString(R.string.notification_restore_error_channel_title)
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
}
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) {
val notification = observerBuilder.apply {
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title))
setContentText(app)
setWhen(Date().time)
setOngoing(true)
setShowWhen(false)
setWhen(System.currentTimeMillis())
setProgress(expected, transferred, false)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
}
fun onBackupResult(app: CharSequence, status: Int, userInitiated: Boolean) {
val title = context.getString(when (status) {
0 -> R.string.notification_backup_result_complete
TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected
else -> R.string.notification_backup_result_error
})
val notification = observerBuilder.apply {
setContentTitle(title)
setContentText(app)
setWhen(Date().time)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) {
if (!userInitiated) {
nm.cancel(NOTIFICATION_ID_OBSERVER)
return
}
val titleRes = if (success) R.string.notification_success_title else R.string.notification_failed_title
val contentText = if (notBackedUp == null) null else {
context.getString(R.string.notification_success_num_not_backed_up, notBackedUp)
}
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
val intent = Intent(context, SettingsActivity::class.java).apply {
action = ACTION_APP_STATUS_LIST
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(iconRes)
setContentTitle(context.getString(titleRes))
setContentText(contentText)
setOngoing(false)
setShowWhen(true)
setAutoCancel(true)
setContentIntent(pendingIntent)
setWhen(System.currentTimeMillis())
setProgress(0, 0, false)
priority = 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 {
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
setSmallIcon(R.drawable.ic_cloud_error)
setContentTitle(context.getString(R.string.notification_error_title))
setContentText(context.getString(R.string.notification_error_text))
setWhen(Date().time)
setWhen(System.currentTimeMillis())
setOnlyAlertOnce(true)
setAutoCancel(true)
mActions = arrayListOf(action)
@ -94,4 +117,34 @@ class BackupNotificationManager(private val context: Context) {
nm.cancel(NOTIFICATION_ID_ERROR)
}
fun onRemovableStorageNotAvailableForRestore(packageName: String, storageName: String) {
val appName = try {
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(appInfo)
} catch (e: NameNotFoundException) {
packageName
}
val intent = Intent(ACTION_RESTORE_ERROR_UNINSTALL).apply {
setPackage(context.packageName)
putExtra(EXTRA_PACKAGE_NAME, packageName)
}
val pendingIntent = PendingIntent.getBroadcast(context, REQUEST_CODE_UNINSTALL, intent, FLAG_UPDATE_CURRENT)
val actionText = context.getString(R.string.notification_restore_error_action)
val action = Action(R.drawable.ic_warning, actionText, pendingIntent)
val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {
setSmallIcon(R.drawable.ic_cloud_error)
setContentTitle(context.getString(R.string.notification_restore_error_title, appName))
setContentText(context.getString(R.string.notification_restore_error_text, storageName))
setWhen(System.currentTimeMillis())
setAutoCancel(true)
priority = PRIORITY_HIGH
mActions = arrayListOf(action)
}.build()
nm.notify(NOTIFICATION_ID_RESTORE_ERROR, notification)
}
fun onRestoreErrorSeen() {
nm.cancel(NOTIFICATION_ID_RESTORE_ERROR)
}
}

View file

@ -0,0 +1,13 @@
package com.stevesoltys.seedvault
/**
* This class only exists, so we can mock the time in tests.
*/
class Clock {
/**
* Returns the current time in milliseconds (Unix time).
*/
fun time(): Long {
return System.currentTimeMillis()
}
}

View file

@ -3,19 +3,30 @@ package com.stevesoltys.seedvault
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import android.util.Log.INFO
import android.util.Log.isLoggable
import com.stevesoltys.seedvault.metadata.MetadataManager
import org.koin.core.KoinComponent
import org.koin.core.inject
private val TAG = NotificationBackupObserver::class.java.simpleName
class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent {
class NotificationBackupObserver(
private val context: Context,
private val expectedPackages: Int,
private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent {
private val pm = context.packageManager
private val nm: BackupNotificationManager by inject()
private val metadataManager: MetadataManager by inject()
private var currentPackage: String? = null
private var numPackages: Int = 0
init {
// we need to show this manually as [onUpdate] isn't called for first @pm@ package
nm.onBackupUpdate(getAppName(MAGIC_PACKAGE_MANAGER), 0, expectedPackages, userInitiated)
}
/**
* This method could be called several times for packages with full data backup.
@ -25,13 +36,7 @@ class NotificationBackupObserver(context: Context, private val userInitiated: Bo
* @param backupProgress Current progress of backup for the package.
*/
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
val transferred = backupProgress.bytesTransferred.toInt()
val expected = backupProgress.bytesExpected.toInt()
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
}
val app = getAppName(currentBackupPackage)
nm.onBackupUpdate(app, transferred, expected, userInitiated)
showProgressNotification(currentBackupPackage)
}
/**
@ -48,7 +53,8 @@ class NotificationBackupObserver(context: Context, private val userInitiated: Bo
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Completed. Target: $target, status: $status")
}
nm.onBackupResult(getAppName(target), status, userInitiated)
// often [onResult] gets called right away without any [onUpdate] call
showProgressNotification(target)
}
/**
@ -60,17 +66,35 @@ class NotificationBackupObserver(context: Context, private val userInitiated: Bo
*/
override fun backupFinished(status: Int) {
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Backup finished. Status: $status")
Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
}
nm.onBackupFinished()
val success = status == 0
val notBackedUp = if (success) metadataManager.getPackagesNumNotBackedUp() else null
nm.onBackupFinished(success, notBackedUp, userInitiated)
}
private fun getAppName(packageId: String): CharSequence = getAppName(pm, packageId)
private fun showProgressNotification(packageName: String) {
if (currentPackage == packageName) return
if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Showing progress notification for $currentPackage $numPackages/$expectedPackages")
}
currentPackage = packageName
val app = getAppName(packageName)
numPackages += 1
nm.onBackupUpdate(app, numPackages, expectedPackages, userInitiated)
}
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
}
fun getAppName(pm: PackageManager, packageId: String): CharSequence {
if (packageId == MAGIC_PACKAGE_MANAGER) return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
fun getAppName(context: Context, packageId: String): CharSequence {
if (packageId == MAGIC_PACKAGE_MANAGER) return context.getString(R.string.restore_magic_package)
return try {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
context.packageManager.getApplicationLabel(appInfo) ?: packageId
} catch (e: NameNotFoundException) {
packageId
}
}

View file

@ -6,25 +6,28 @@ import android.content.Intent
import android.database.ContentObserver
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager.*
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED
import android.hardware.usb.UsbManager.EXTRA_DEVICE
import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.util.Log
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.*
import org.koin.core.context.GlobalContext.get
import java.util.concurrent.TimeUnit.HOURS
private val TAG = UsbIntentReceiver::class.java.simpleName
class UsbIntentReceiver : UsbMonitor(), KoinComponent {
class UsbIntentReceiver : UsbMonitor() {
private val settingsManager by inject<SettingsManager>()
// using KoinComponent would crash robolectric tests :(
private val settingsManager: SettingsManager by lazy { get().koin.get<SettingsManager>() }
private val metadataManager: MetadataManager by lazy { get().koin.get<MetadataManager>() }
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
if (action != ACTION_USB_DEVICE_ATTACHED) return false
@ -33,7 +36,7 @@ class UsbIntentReceiver : UsbMonitor(), KoinComponent {
val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...")
if (Date().time - settingsManager.getBackupTime() >= HOURS.toMillis(24)) {
if (System.currentTimeMillis() - metadataManager.getLastBackupTime() >= HOURS.toMillis(24)) {
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...")
true
} else {

View file

@ -1,6 +1,12 @@
package com.stevesoltys.seedvault.crypto
import com.stevesoltys.seedvault.header.*
import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
import com.stevesoltys.seedvault.header.SegmentHeader
import com.stevesoltys.seedvault.header.VersionHeader
import java.io.EOFException
import java.io.IOException
import java.io.InputStream

View file

@ -1,7 +1,9 @@
package com.stevesoltys.seedvault.crypto
import android.os.Build.VERSION.SDK_INT
import android.security.keystore.KeyProperties.*
import android.security.keystore.KeyProperties.BLOCK_MODE_GCM
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
import android.security.keystore.KeyProtection
import java.security.KeyStore
import java.security.KeyStore.SecretKeyEntry
@ -45,7 +47,7 @@ internal class KeyManagerImpl : KeyManager {
override fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
// TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe!
// TODO check if using first 256 of 512 bits produced by PBKDF2WithHmacSHA512 is safe!
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
val ksEntry = SecretKeyEntry(secretKeySpec)
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
@ -65,7 +67,7 @@ internal class KeyManagerImpl : KeyManager {
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
// unlocking is required only for decryption, so when restoring from backup
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
builder.setUnlockedDeviceRequired(true)
return builder.build()
}

View file

@ -1,25 +1,88 @@
package com.stevesoltys.seedvault.metadata
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import java.io.InputStream
typealias PackageMetadataMap = HashMap<String, PackageMetadata>
data class BackupMetadata(
internal val version: Byte = VERSION,
internal val token: Long,
internal val androidVersion: Int = SDK_INT,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}"
internal var time: Long = 0L,
internal val androidVersion: Int = Build.VERSION.SDK_INT,
internal val androidIncremental: String = Build.VERSION.INCREMENTAL,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}",
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap()
)
internal const val JSON_VERSION = "version"
internal const val JSON_TOKEN = "token"
internal const val JSON_ANDROID_VERSION = "androidVersion"
internal const val JSON_DEVICE_NAME = "deviceName"
internal const val JSON_METADATA = "@meta@"
internal const val JSON_METADATA_VERSION = "version"
internal const val JSON_METADATA_TOKEN = "token"
internal const val JSON_METADATA_TIME = "time"
internal const val JSON_METADATA_SDK_INT = "sdk_int"
internal const val JSON_METADATA_INCREMENTAL = "incremental"
internal const val JSON_METADATA_NAME = "name"
enum class PackageState {
/**
* Both, the APK and the package's data was backed up.
* This is the expected state of all user-installed packages.
*/
APK_AND_DATA,
/**
* Package data could not get backed up, because the app exceeded the allowed quota.
*/
QUOTA_EXCEEDED,
/**
* Package data could not get backed up, because the app reported no data to back up.
*/
NO_DATA,
/**
* Package data could not get backed up, because it was not allowed.
* Most often, this is a manifest opt-out, but it could also be a disabled or system-user app.
*/
NOT_ALLOWED,
/**
* Package data could not get backed up, because an error occurred during backup.
*/
UNKNOWN_ERROR,
}
data class PackageMetadata(
/**
* The timestamp in milliseconds of the last app data backup.
* It is 0 if there never was a data backup.
*/
internal var time: Long = 0L,
internal var state: PackageState = UNKNOWN_ERROR,
internal val system: Boolean = false,
internal val version: Long? = null,
internal val installer: String? = null,
internal val sha256: String? = null,
internal val signatures: List<String>? = null
) {
fun hasApk(): Boolean {
return version != null && sha256 != null && signatures != null
}
}
internal const val JSON_PACKAGE_TIME = "time"
internal const val JSON_PACKAGE_STATE = "state"
internal const val JSON_PACKAGE_SYSTEM = "system"
internal const val JSON_PACKAGE_VERSION = "version"
internal const val JSON_PACKAGE_INSTALLER = "installer"
internal const val JSON_PACKAGE_SHA256 = "sha256"
internal const val JSON_PACKAGE_SIGNATURES = "signatures"
internal class DecryptionFailedException(cause: Throwable) : Exception(cause)
class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) {
class EncryptedBackupMetadata private constructor(
val token: Long,
val inputStream: InputStream?,
val error: Boolean) {
constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false)
/**
* Indicates that there was an error retrieving the encrypted backup metadata.

View file

@ -0,0 +1,231 @@
package com.stevesoltys.seedvault.metadata
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.distinctUntilChanged
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
private val TAG = MetadataManager::class.java.simpleName
@VisibleForTesting
internal const val METADATA_CACHE_FILE = "metadata.cache"
@WorkerThread
class MetadataManager(
private val context: Context,
private val clock: Clock,
private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader) {
private val uninitializedMetadata = BackupMetadata(token = 0L)
private var metadata: BackupMetadata = uninitializedMetadata
get() {
if (field == uninitializedMetadata) {
field = try {
getMetadataFromCache() ?: throw IOException()
} catch (e: IOException) {
// If this happens, it is hard to recover from this. Let's hope it never does.
throw AssertionError("Error reading metadata from cache", e)
}
mLastBackupTime.postValue(field.time)
}
return field
}
/**
* Call this when initializing a new device.
*
* Existing [BackupMetadata] will be cleared, use the given new token,
* and written encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
modifyMetadata(metadataOutputStream) {
metadata = BackupMetadata(token = token)
}
}
/**
* Call this after a package's APK has been backed up successfully.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onApkBackedUp(packageInfo: PackageInfo, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) {
val packageName = packageInfo.packageName
metadata.packageMetadataMap[packageName]?.let {
check(packageMetadata.version != null) {
"APK backup returned version null"
}
check(it.version == null || it.version < packageMetadata.version) {
"APK backup backed up the same or a smaller version: was ${it.version} is ${packageMetadata.version}"
}
}
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata()
// only allow state change if backup of this package is not allowed
val newState = if (packageMetadata.state == NOT_ALLOWED)
packageMetadata.state
else
oldPackageMetadata.state
modifyMetadata(metadataOutputStream) {
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
state = newState,
system = packageInfo.isSystemApp(),
version = packageMetadata.version,
installer = packageMetadata.installer,
sha256 = packageMetadata.sha256,
signatures = packageMetadata.signatures
)
}
}
/**
* Call this after a package has been backed up successfully.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onPackageBackedUp(packageInfo: PackageInfo, metadataOutputStream: OutputStream) {
val packageName = packageInfo.packageName
modifyMetadata(metadataOutputStream) {
val now = clock.time()
metadata.time = now
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]!!.time = now
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = now,
state = APK_AND_DATA,
system = packageInfo.isSystemApp()
)
}
}
}
/**
* Call this after a package data backup failed.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
internal fun onPackageBackupError(packageInfo: PackageInfo, packageState: PackageState, metadataOutputStream: OutputStream) {
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
val packageName = packageInfo.packageName
modifyMetadata(metadataOutputStream) {
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]!!.state = packageState
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = 0L,
state = packageState,
system = packageInfo.isSystemApp()
)
}
}
}
@Throws(IOException::class)
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
val oldMetadata = metadata.copy()
try {
modFun.invoke()
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
} catch (e: IOException) {
Log.w(TAG, "Error writing metadata to storage", e)
// revert metadata and do not write it to cache
metadata = oldMetadata
throw IOException(e)
}
mLastBackupTime.postValue(metadata.time)
}
/**
* Returns the current backup token.
*
* If the token is 0L, it is not yet initialized and must not be used for anything.
*/
@Synchronized
fun getBackupToken(): Long = metadata.token
/**
* Returns the last backup time in unix epoch milli seconds.
*
* Note that this might be a blocking I/O call.
*/
@Synchronized
fun getLastBackupTime(): Long = mLastBackupTime.value ?: metadata.time
private val mLastBackupTime = MutableLiveData<Long>()
internal val lastBackupTime: LiveData<Long> = mLastBackupTime.distinctUntilChanged()
@Synchronized
fun getPackageMetadata(packageName: String): PackageMetadata? {
return metadata.packageMetadataMap[packageName]?.copy()
}
@Synchronized
fun getPackagesNumNotBackedUp(): Int {
return metadata.packageMetadataMap.filter { (_, packageMetadata) ->
!packageMetadata.system && packageMetadata.state != APK_AND_DATA
}.count()
}
@Synchronized
@VisibleForTesting
private fun getMetadataFromCache(): BackupMetadata? {
try {
with(context.openFileInput(METADATA_CACHE_FILE)) {
return metadataReader.decode(readBytes())
}
} catch (e: SecurityException) {
Log.e(TAG, "Error parsing cached metadata", e)
return null
} catch (e: FileNotFoundException) {
Log.d(TAG, "Cached metadata not found, creating...")
return uninitializedMetadata
}
}
@Synchronized
@VisibleForTesting
@Throws(IOException::class)
private fun writeMetadataToCache() {
with(context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE)) {
write(metadataWriter.encode(metadata))
}
}
}
fun PackageInfo.isSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
return applicationInfo.flags and FLAG_SYSTEM != 0
}
fun PackageInfo.isUpdatedSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
}

View file

@ -1,8 +1,10 @@
package com.stevesoltys.seedvault.metadata
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val metadataModule = module {
single { MetadataManager(androidContext(), get(), get(), get()) }
single<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) }
}

View file

@ -1,10 +1,14 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
@ -16,6 +20,9 @@ interface MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata
@Throws(SecurityException::class)
fun decode(bytes: ByteArray, expectedVersion: Byte? = null, expectedToken: Long? = null): BackupMetadata
}
internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
@ -33,9 +40,8 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
return decode(metadataBytes, version, expectedToken)
}
@VisibleForTesting
@Throws(SecurityException::class)
internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
override fun decode(bytes: ByteArray, expectedVersion: Byte?, expectedToken: Long?): BackupMetadata {
// NOTE: We don't do extensive validation of the parsed input here,
// because it was encrypted with authentication, so we should be able to trust it.
//
@ -43,19 +49,57 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
// matches the authenticated version and token in the JSON.
try {
val json = JSONObject(bytes.toString(Utf8))
val version = json.getInt(JSON_VERSION).toByte()
if (version != expectedVersion) {
// get backup metadata and check expectations
val meta = json.getJSONObject(JSON_METADATA)
val version = meta.getInt(JSON_METADATA_VERSION).toByte()
if (expectedVersion != null && version != expectedVersion) {
throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.")
}
val token = json.getLong(JSON_TOKEN)
if (token != expectedToken) {
val token = meta.getLong(JSON_METADATA_TOKEN)
if (expectedToken != null && token != expectedToken) {
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
// get package metadata
val packageMetadataMap = PackageMetadataMap()
for (packageName in json.keys()) {
if (packageName == JSON_METADATA) continue
val p = json.getJSONObject(packageName)
val pState = when(p.optString(JSON_PACKAGE_STATE)) {
"" -> APK_AND_DATA
QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED
NO_DATA.name -> NO_DATA
NOT_ALLOWED.name -> NOT_ALLOWED
else -> UNKNOWN_ERROR
}
val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER)
val pSha256 = p.optString(JSON_PACKAGE_SHA256)
val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES)
val signatures = if (pSignatures == null) null else
ArrayList<String>(pSignatures.length()).apply {
for (i in (0 until pSignatures.length())) {
add(pSignatures.getString(i))
}
}
packageMetadataMap[packageName] = PackageMetadata(
time = p.getLong(JSON_PACKAGE_TIME),
state = pState,
system = pSystem,
version = if (pVersion == 0L) null else pVersion,
installer = if (pInstaller == "") null else pInstaller,
sha256 = if (pSha256 == "") null else pSha256,
signatures = signatures
)
}
return BackupMetadata(
version = version,
token = token,
androidVersion = json.getInt(JSON_ANDROID_VERSION),
deviceName = json.getString(JSON_DEVICE_NAME)
time = meta.getLong(JSON_METADATA_TIME),
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
deviceName = meta.getString(JSON_METADATA_NAME),
packageMetadataMap = packageMetadataMap
)
} catch (e: JSONException) {
throw SecurityException(e)

View file

@ -1,35 +1,54 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.io.OutputStream
interface MetadataWriter {
@Throws(IOException::class)
fun write(outputStream: OutputStream, token: Long)
fun write(metadata: BackupMetadata, outputStream: OutputStream)
fun encode(metadata: BackupMetadata): ByteArray
}
internal class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
@Throws(IOException::class)
override fun write(outputStream: OutputStream, token: Long) {
val metadata = BackupMetadata(token = token)
override fun write(metadata: BackupMetadata, outputStream: OutputStream) {
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.encryptMultipleSegments(outputStream, encode(metadata))
}
@VisibleForTesting
internal fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject()
json.put(JSON_VERSION, metadata.version.toInt())
json.put(JSON_TOKEN, metadata.token)
json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
json.put(JSON_DEVICE_NAME, metadata.deviceName)
override fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject().apply {
put(JSON_METADATA, JSONObject().apply {
put(JSON_METADATA_VERSION, metadata.version.toInt())
put(JSON_METADATA_TOKEN, metadata.token)
put(JSON_METADATA_TIME, metadata.time)
put(JSON_METADATA_SDK_INT, metadata.androidVersion)
put(JSON_METADATA_INCREMENTAL, metadata.androidIncremental)
put(JSON_METADATA_NAME, metadata.deviceName)
})
}
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
json.put(packageName, JSONObject().apply {
put(JSON_PACKAGE_TIME, packageMetadata.time)
if (packageMetadata.state != APK_AND_DATA) {
put(JSON_PACKAGE_STATE, packageMetadata.state.name)
}
if (packageMetadata.system) {
put(JSON_PACKAGE_SYSTEM, packageMetadata.system)
}
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) }
})
}
return json.toString().toByteArray(Utf8)
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
@ -7,6 +8,8 @@ import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException
import java.io.OutputStream
private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
internal class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin {
@ -20,7 +23,13 @@ internal class DocumentsProviderBackupPlugin(
}
@Throws(IOException::class)
override fun initializeDevice() {
override fun initializeDevice(newToken: Long): Boolean {
// check if storage is already initialized
if (storage.isInitialized()) return false
// reset current storage
storage.reset(newToken)
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
@ -32,6 +41,8 @@ internal class DocumentsProviderBackupPlugin(
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
return true
}
@Throws(IOException::class)
@ -41,6 +52,13 @@ internal class DocumentsProviderBackupPlugin(
return storage.getOutputStream(metadataFile)
}
@Throws(IOException::class)
override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException()
val file = setDir.createOrGetFile("${packageInfo.packageName}.apk", MIME_TYPE_APK)
return storage.getOutputStream(file)
}
override val providerPackageName: String? by lazy {
val authority = storage.getAuthority() ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null

View file

@ -6,7 +6,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get()) }
single { DocumentsStorage(androidContext(), get(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
}

View file

@ -9,7 +9,9 @@ import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@ -84,6 +86,13 @@ internal class DocumentsProviderRestorePlugin(
return backupSets
}
@Throws(IOException::class)
override fun getApkInputStream(token: Long, packageName: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
return storage.getInputStream(file)
}
}
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -5,10 +5,17 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.database.ContentObserver
import android.net.Uri
import android.provider.DocumentsContract.*
import android.provider.DocumentsContract.Document.*
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly
@ -17,7 +24,7 @@ import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES
const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
const val FILE_BACKUP_METADATA = ".backup.metadata"
@ -28,14 +35,20 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager) {
private val storage: Storage? = settingsManager.getStorage()
private val token: Long = settingsManager.getBackupToken()
internal var storage: Storage? = null
get() {
if (field == null) field = settingsManager.getStorage()
return field
}
internal val rootBackupDir: DocumentFile? by lazy {
val parent = storage?.getDocumentFile(context) ?: return@lazy null
try {
internal var rootBackupDir: DocumentFile? = null
get() {
if (field == null) {
val parent = storage?.getDocumentFile(context) ?: return null
field = try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA)
@ -45,41 +58,70 @@ internal class DocumentsStorage(
null
}
}
private val currentToken: Long by lazy {
if (token != 0L) token
else settingsManager.getAndSaveNewBackupToken().apply {
Log.d(TAG, "Using a fresh backup token: $this")
}
return field
}
private val currentSetDir: DocumentFile? by lazy {
val currentSetName = currentToken.toString()
try {
rootBackupDir?.createOrGetDirectory(currentSetName)
private var currentToken: Long = 0L
get() {
if (field == 0L) field = metadataManager.getBackupToken()
return field
}
private var currentSetDir: DocumentFile? = null
get() {
if (field == null) {
if (currentToken == 0L) return null
field = try {
rootBackupDir?.createOrGetDirectory(currentToken.toString())
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
return field
}
val currentFullBackupDir: DocumentFile? by lazy {
try {
var currentFullBackupDir: DocumentFile? = null
get() {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
return field
}
val currentKvBackupDir: DocumentFile? by lazy {
try {
var currentKvBackupDir: DocumentFile? = null
get() {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
return field
}
fun isInitialized(): Boolean {
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
return kvEmpty && fullEmpty
}
fun reset(newToken: Long) {
storage = null
currentToken = newToken
rootBackupDir = null
currentSetDir = null
currentKvBackupDir = null
currentFullBackupDir = null
}
fun getAuthority(): String? = storage?.uri?.authority

View file

@ -0,0 +1,67 @@
package com.stevesoltys.seedvault.restore
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.ApkRestoreResult
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder
internal class InstallProgressAdapter : Adapter<AppInstallViewHolder>() {
private val items = SortedList<ApkRestoreResult>(ApkRestoreResult::class.java, object : SortedListAdapterCallback<ApkRestoreResult>(this) {
override fun areItemsTheSame(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.packageName == item2.packageName
override fun areContentsTheSame(oldItem: ApkRestoreResult, newItem: ApkRestoreResult) = oldItem == newItem
override fun compare(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.compareTo(item2)
})
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false)
return AppInstallViewHolder(v)
}
override fun getItemCount() = items.size()
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
holder.bind(items[position])
}
fun update(items: Collection<ApkRestoreResult>) {
this.items.replaceAll(items)
}
}
internal class AppInstallViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: ApkRestoreResult) {
appIcon.setImageDrawable(item.icon)
appName.text = item.name
when (item.status) {
IN_PROGRESS -> {
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
}
SUCCEEDED -> {
appStatus.setImageResource(R.drawable.ic_check_green)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
}
FAILED -> {
appStatus.setImageResource(R.drawable.ic_error_red)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
}
QUEUED -> throw AssertionError()
}
}
}

View file

@ -0,0 +1,74 @@
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.InstallResult
import com.stevesoltys.seedvault.transport.restore.getInProgress
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
titleView.setText(R.string.restore_installing_packages)
appList.apply {
layoutManager = this@InstallProgressFragment.layoutManager
adapter = this@InstallProgressFragment.adapter
addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
button.setText(R.string.restore_next)
button.setOnClickListener { viewModel.onNextClicked() }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.chosenRestorableBackup.observe(this, Observer { restorableBackup ->
backupNameView.text = restorableBackup.name
})
viewModel.installResult.observe(this, Observer { result ->
onInstallResult(result)
})
viewModel.nextButtonEnabled.observe(this, Observer { enabled ->
button.isEnabled = enabled
})
}
private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install
if (installResult.isEmpty()) viewModel.onNextClicked()
val result = installResult.filterValues { it.status != QUEUED }
val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(result.values)
if (position == 0) layoutManager.scrollToPosition(0)
result.getInProgress()?.let {
progressBar.progress = it.progress
progressBar.max = it.total
}
}
}

View file

@ -0,0 +1,22 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
data class RestorableBackup(private val restoreSet: RestoreSet,
private val backupMetadata: BackupMetadata) {
val name: String
get() = restoreSet.name
val token: Long
get() = restoreSet.token
val time: Long
get() = backupMetadata.time
val packageMetadataMap: PackageMetadataMap
get() = backupMetadata.packageMetadataMap
}

View file

@ -2,8 +2,10 @@ package com.stevesoltys.seedvault.restore
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.ui.LiveEventHandler
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
@ -21,8 +23,12 @@ class RestoreActivity : RequireProvisioningActivity() {
setContentView(R.layout.activity_fragment_container)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
if (set != null) showFragment(RestoreProgressFragment())
viewModel.displayFragment.observeEvent(this, LiveEventHandler { fragment ->
when (fragment) {
RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
else -> throw AssertionError()
}
})
if (savedInstanceState == null) {

View file

@ -0,0 +1,34 @@
package com.stevesoltys.seedvault.restore
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import androidx.core.net.toUri
import com.stevesoltys.seedvault.BackupNotificationManager
import org.koin.core.context.GlobalContext.get
internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL"
internal const val EXTRA_PACKAGE_NAME = "com.stevesoltys.seedvault.extra.PACKAGE_NAME"
internal const val REQUEST_CODE_UNINSTALL = 4576841
class RestoreErrorBroadcastReceiver : BroadcastReceiver() {
// using KoinComponent would crash robolectric tests :(
private val notificationManager: BackupNotificationManager by lazy { get().koin.get<BackupNotificationManager>() }
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_RESTORE_ERROR_UNINSTALL) return
notificationManager.onRestoreErrorSeen()
val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME)!!
@Suppress("DEPRECATION") // the alternative doesn't work for us
val i = Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply {
data = "package:$packageName".toUri()
flags = FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(i)
}
}

View file

@ -0,0 +1,84 @@
package com.stevesoltys.seedvault.restore
import android.content.pm.PackageManager.NameNotFoundException
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.Adapter
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
import java.util.*
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
private val items = LinkedList<AppRestoreResult>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false)
return PackageViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: PackageViewHolder, position: Int) {
holder.bind(items[position])
}
fun update(newItems: LinkedList<AppRestoreResult>) {
val diffResult = DiffUtil.calculateDiff(Diff(items, newItems))
items.clear()
items.addAll(newItems)
diffResult.dispatchUpdatesTo(this)
}
private class Diff(
private val oldItems: LinkedList<AppRestoreResult>,
private val newItems: LinkedList<AppRestoreResult>) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition] == newItems[newItemPosition]
}
}
inner class PackageViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: AppRestoreResult) {
appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
appIcon.setImageResource(R.drawable.ic_launcher_default)
} else {
try {
appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName))
} catch (e: NameNotFoundException) {
appIcon.setImageResource(R.drawable.ic_launcher_default)
}
}
setStatus(item.status)
}
}
}
enum class AppRestoreStatus {
IN_PROGRESS,
SUCCEEDED,
FAILED,
FAILED_NO_DATA,
FAILED_NOT_ALLOWED,
FAILED_QUOTA_EXCEEDED,
FAILED_NOT_INSTALLED,
}
internal data class AppRestoreResult(
val packageName: String,
val name: CharSequence,
val status: AppRestoreStatus)

View file

@ -4,70 +4,78 @@ import android.app.Activity.RESULT_OK
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.isDebugBuild
import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject()
private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
titleView.setText(R.string.restore_restoring)
appList.apply {
layoutManager = this@RestoreProgressFragment.layoutManager
adapter = this@RestoreProgressFragment.adapter
addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
button.setText(R.string.restore_finished_button)
button.setOnClickListener {
requireActivity().setResult(RESULT_OK)
requireActivity().finishAfterTransition()
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
backupNameView.text = set.device
viewModel.chosenRestorableBackup.observe(this, Observer { restorableBackup ->
backupNameView.text = restorableBackup.name
progressBar.max = restorableBackup.packageMetadataMap.size
})
viewModel.restoreProgress.observe(this, Observer { currentPackage ->
val appName = getAppName(requireActivity().packageManager, currentPackage)
val displayName = if (isDebugBuild()) "$appName (${currentPackage})" else appName
currentPackageView.text = getString(R.string.restore_current_package, displayName)
viewModel.restoreProgress.observe(this, Observer { list ->
stayScrolledAtTop { adapter.update(list) }
progressBar.progress = list.size
})
viewModel.restoreFinished.observe(this, Observer { finished ->
progressBar.visibility = INVISIBLE
button.visibility = VISIBLE
if (finished == 0) {
// success
currentPackageView.text = getString(R.string.restore_finished_success)
warningView.text = if (settingsManager.getStorage()?.isUsb == true) {
getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable))
viewModel.restoreBackupResult.observe(this, Observer { finished ->
button.isEnabled = true
if (finished.hasError()) {
backupNameView.text = finished.errorMsg
backupNameView.setTextColor(getColor(requireContext(), R.color.red))
} else {
getString(R.string.restore_finished_warning_only_installed, null)
}
warningView.visibility = VISIBLE
} else {
// error
currentPackageView.text = getString(R.string.restore_finished_error)
currentPackageView.setTextColor(warningView.textColors)
backupNameView.text = getString(R.string.restore_finished_success)
}
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
})
button.setOnClickListener {
requireActivity().setResult(RESULT_OK)
requireActivity().finishAfterTransition()
}
private fun stayScrolledAtTop(add: () -> Unit) {
val position = layoutManager.findFirstVisibleItemPosition()
add.invoke()
if (position == 0) layoutManager.scrollToPosition(0)
}
}

View file

@ -1,6 +1,8 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
import android.text.format.DateUtils.HOUR_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -11,8 +13,8 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
internal class RestoreSetAdapter(
private val listener: RestoreSetClickListener,
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
private val listener: RestorableBackupClickListener,
private val items: List<RestorableBackup>) : Adapter<RestoreSetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
val v = LayoutInflater.from(parent.context)
@ -31,10 +33,18 @@ internal class RestoreSetAdapter(
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
internal fun bind(item: RestoreSet) {
v.setOnClickListener { listener.onRestoreSetClicked(item) }
internal fun bind(item: RestorableBackup) {
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
titleView.text = item.name
subtitleView.text = "Android Backup" // TODO change to backup date when available
val lastBackup = getRelativeTime(item.time)
val setup = getRelativeTime(item.token)
subtitleView.text = v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
}
private fun getRelativeTime(time: Long): CharSequence {
val now = System.currentTimeMillis()
return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
}
}

View file

@ -1,12 +1,12 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
@ -25,7 +25,10 @@ class RestoreSetFragment : Fragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.restoreSetResults.observe(this, Observer { result -> onRestoreResultsLoaded(result) })
backView.setOnClickListener { requireActivity().finishAfterTransition() }
}
@ -37,24 +40,24 @@ class RestoreSetFragment : Fragment() {
}
}
private fun onRestoreSetsLoaded(result: RestoreSetResult) {
if (result.hasError()) {
private fun onRestoreResultsLoaded(results: RestoreSetResult) {
if (results.hasError()) {
errorView.visibility = VISIBLE
listView.visibility = INVISIBLE
progressBar.visibility = INVISIBLE
errorView.text = result.errorMsg
errorView.text = results.errorMsg
} else {
errorView.visibility = INVISIBLE
listView.visibility = VISIBLE
progressBar.visibility = INVISIBLE
listView.adapter = RestoreSetAdapter(viewModel, result.sets)
listView.adapter = RestoreSetAdapter(viewModel, results.restorableBackups)
}
}
}
internal interface RestoreSetClickListener {
fun onRestoreSetClicked(set: RestoreSet)
internal interface RestorableBackupClickListener {
fun onRestorableBackupClicked(restorableBackup: RestorableBackup)
}

View file

@ -5,18 +5,55 @@ import android.app.backup.IBackupManager
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.content.pm.PackageManager
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.transport.restore.ApkRestore
import com.stevesoltys.seedvault.transport.restore.InstallResult
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.util.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
private val TAG = RestoreViewModel::class.java.simpleName
@ -24,69 +61,204 @@ internal class RestoreViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val backupManager: IBackupManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestoreSetClickListener {
private val backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener {
override val isRestoreOperation = true
private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null
private val monitor = BackupMonitor()
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
private val mChosenRestoreSet = MutableLiveData<RestoreSet>()
internal val chosenRestoreSet: LiveData<RestoreSet> get() = mChosenRestoreSet
private val mRestoreSetResults = MutableLiveData<RestoreSetResult>()
internal val restoreSetResults: LiveData<RestoreSetResult> get() = mRestoreSetResults
private val mRestoreProgress = MutableLiveData<String>()
internal val restoreProgress: LiveData<String> get() = mRestoreProgress
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
private val mRestoreFinished = MutableLiveData<Int>()
// Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
internal fun loadRestoreSets() {
val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
this.session = session
if (session == null) {
Log.e(TAG, "beginRestoreSession() returned null session")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
internal val installResult: LiveData<InstallResult> = switchMap(mChosenRestorableBackup) { backup ->
@Suppress("EXPERIMENTAL_API_USAGE")
getInstallResult(backup)
}
val observer = this.observer ?: RestoreObserver()
this.observer = observer
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false }
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
value = LinkedList<AppRestoreResult>().apply {
add(AppRestoreResult(MAGIC_PACKAGE_MANAGER, getAppName(app, MAGIC_PACKAGE_MANAGER), IN_PROGRESS))
}
}
internal val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
@Throws(RemoteException::class)
private fun getOrStartSession(): IRestoreSession {
val session = this.session
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
?: throw RemoteException("beginRestoreSessionForUser returned null")
this.session = session
return session
}
internal fun loadRestoreSets() = viewModelScope.launch {
mRestoreSetResults.value = getAvailableRestoreSets()
}
private suspend fun getAvailableRestoreSets() = suspendCoroutine<RestoreSetResult> { continuation ->
val session = try {
getOrStartSession()
} catch (e: RemoteException) {
Log.e(TAG, "Error starting new session", e)
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
return@suspendCoroutine
}
val observer = RestoreObserver(continuation)
val setResult = session.getAvailableRestoreSets(observer, monitor)
if (setResult != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
return@suspendCoroutine
}
}
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
mChosenRestorableBackup.value = restorableBackup
mDisplayFragment.setEvent(RESTORE_APPS)
// re-installing apps will take some time and the session probably times out
// so better close it cleanly and re-open it later
closeSession()
}
@ExperimentalCoroutinesApi
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
.onStart {
Log.d(TAG, "Start InstallResult Flow")
}.catch { e ->
Log.d(TAG, "Exception in InstallResult Flow", e)
}.onCompletion { e ->
Log.d(TAG, "Completed InstallResult Flow", e)
mNextButtonEnabled.postValue(true)
}
.flowOn(ioDispatcher)
.asLiveData()
}
internal fun onNextClicked() {
mDisplayFragment.postEvent(RESTORE_BACKUP)
val token = mChosenRestorableBackup.value?.token ?: throw AssertionError()
viewModelScope.launch(ioDispatcher) {
startRestore(token)
}
}
@WorkerThread
private suspend fun startRestore(token: Long) {
Log.d(TAG, "Starting new restore session to restore backup $token")
// we need to start a new session and retrieve the restore sets before starting the restore
val restoreSetResult = getAvailableRestoreSets()
if (restoreSetResult.hasError()) {
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
return
}
// now we can start the restore of all available packages
val observer = RestoreObserver()
val restoreAllResult = session?.restoreAll(token, observer, monitor) ?: 1
if (restoreAllResult != 0) {
if (session == null) Log.e(TAG, "session was null")
else Log.e(TAG, "restoreAll() returned non-zero value")
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
return
}
}
override fun onRestoreSetClicked(set: RestoreSet) {
val session = this.session
check(session != null) { "Restore set clicked, but no session available" }
session.restoreAll(set.token, observer, monitor)
@WorkerThread
// this should be called one package at a time and never concurrently for different packages
private fun onRestoreStarted(packageName: String) {
// list is never null and always has at least one package
val list = mRestoreProgress.value!!
mChosenRestoreSet.value = set
// check previous package first and change status
updateLatestPackage(list)
// add current package
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), IN_PROGRESS))
mRestoreProgress.postValue(list)
}
@WorkerThread
private fun updateLatestPackage(list: LinkedList<AppRestoreResult>) {
val latestResult = list[0]
if (restoreCoordinator.isFailedPackage(latestResult.packageName)) {
list[0] = latestResult.copy(status = getFailedStatus(latestResult.packageName))
} else {
list[0] = latestResult.copy(status = SUCCEEDED)
}
}
@WorkerThread
private fun getFailedStatus(packageName: String, restorableBackup: RestorableBackup = chosenRestorableBackup.value!!): AppRestoreStatus {
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
return when (metadata.state) {
NO_DATA -> FAILED_NO_DATA
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> {
try {
app.packageManager.getPackageInfo(packageName, 0)
FAILED
} catch (e: PackageManager.NameNotFoundException) {
FAILED_NOT_INSTALLED
}
}
}
}
@WorkerThread
private fun onRestoreComplete(result: RestoreBackupResult) {
// update status of latest package
val list = mRestoreProgress.value!!
updateLatestPackage(list)
// add missing packages as failed
val seenPackages = list.map { it.packageName }
val restorableBackup = chosenRestorableBackup.value!!
val expectedPackages = restorableBackup.packageMetadataMap.keys
expectedPackages.removeAll(seenPackages)
for (packageName: String in expectedPackages) {
// TODO don't add if it was a NO_DATA system app
val failedStatus = getFailedStatus(packageName, restorableBackup)
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), failedStatus))
}
mRestoreProgress.postValue(list)
mRestoreBackupResult.postValue(result)
}
override fun onCleared() {
super.onCleared()
endSession()
closeSession()
}
private fun endSession() {
private fun closeSession() {
session?.endRestoreSession()
session = null
observer = null
}
@WorkerThread
private inner class RestoreObserver : IRestoreObserver.Stub() {
private inner class RestoreObserver(private val continuation: Continuation<RestoreSetResult>? = null) : IRestoreObserver.Stub() {
/**
* Supply a list of the restore datasets available from the current transport.
@ -98,11 +270,36 @@ internal class RestoreViewModel(
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
if (restoreSets == null || restoreSets.isEmpty()) {
mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
check(continuation != null) { "Getting restore sets without continuation" }
val result = if (restoreSets == null || restoreSets.isEmpty()) {
RestoreSetResult(app.getString(R.string.restore_set_empty_result))
} else {
mRestoreSets.postValue(RestoreSetResult(restoreSets))
val backupMetadata = restoreCoordinator.getAndClearBackupMetadata()
if (backupMetadata == null) {
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() returned null")
RestoreSetResult(app.getString(R.string.restore_set_error))
} else {
val restorableBackups = restoreSets.mapNotNull { set ->
val metadata = backupMetadata[set.token]
when {
metadata == null -> {
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() has no metadata for token ${set.token}.")
null
}
metadata.time == 0L -> {
Log.d(TAG, "Ignoring RestoreSet with no last backup time: ${set.token}.")
null
}
else -> {
RestorableBackup(set, metadata)
}
}
}
RestoreSetResult(restorableBackups)
}
}
continuation.resume(result)
}
/**
@ -125,7 +322,7 @@ internal class RestoreViewModel(
*/
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
// nowBeingRestored reporting is buggy, so don't use it
mRestoreProgress.postValue(currentPackage)
onRestoreStarted(currentPackage)
}
/**
@ -135,8 +332,12 @@ internal class RestoreViewModel(
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
mRestoreFinished.postValue(result)
endSession()
val restoreResult = RestoreBackupResult(
if (result == 0) null
else app.getString(R.string.restore_finished_error)
)
onRestoreComplete(restoreResult)
closeSession()
}
}
@ -144,12 +345,18 @@ internal class RestoreViewModel(
}
internal class RestoreSetResult(
internal val sets: Array<out RestoreSet>,
internal val restorableBackups: List<RestorableBackup>,
internal val errorMsg: String?) {
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
internal constructor(restorableBackups: List<RestorableBackup>) : this(restorableBackups, null)
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
internal constructor(errorMsg: String) : this(emptyList(), errorMsg)
internal fun hasError(): Boolean = errorMsg != null
}
internal class RestoreBackupResult(val errorMsg: String? = null) {
internal fun hasError(): Boolean = errorMsg != null
}
internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP }

View file

@ -23,6 +23,7 @@ class AboutDialogFragment : DialogFragment() {
val linkMovementMethod = LinkMovementMethod.getInstance()
licenseView.movementMethod = linkMovementMethod
authorView.movementMethod = linkMovementMethod
designView.movementMethod = linkMovementMethod
sponsorView.movementMethod = linkMovementMethod
}

View file

@ -0,0 +1,122 @@
package com.stevesoltys.seedvault.settings
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DiffUtil.DiffResult
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.AppRestoreStatus
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.settings.AppStatusAdapter.AppStatusViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.toRelativeTime
internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListener) : Adapter<AppStatusViewHolder>() {
private val items = ArrayList<AppStatus>()
private var editMode = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppStatusViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false)
return AppStatusViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: AppStatusViewHolder, position: Int) {
holder.bind(items[position])
}
fun setEditMode(enabled: Boolean) {
editMode = enabled
notifyDataSetChanged()
}
fun update(newItems: List<AppStatus>, diff: DiffResult) {
items.clear()
items.addAll(newItems)
diff.dispatchUpdatesTo(this)
}
fun onItemChanged(item: AppStatus) {
val pos = items.indexOfFirst { it.packageName == item.packageName }
if (pos != NO_POSITION) notifyItemChanged(pos, item)
}
inner class AppStatusViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: AppStatus) {
appName.text = item.name
appIcon.setImageDrawable(item.icon)
if (editMode) {
v.background = clickableBackground
v.setOnClickListener {
switchView.toggle()
item.enabled = switchView.isChecked
toggleListener.onAppStatusToggled(item)
}
appInfo.visibility = GONE
appStatus.visibility = INVISIBLE
progressBar.visibility = INVISIBLE
switchView.visibility = VISIBLE
switchView.isChecked = item.enabled
} else {
v.background = null
v.setOnClickListener(null)
setStatus(item.status)
if (item.status == SUCCEEDED) {
appInfo.text = item.time.toRelativeTime(context)
appInfo.visibility = VISIBLE
}
switchView.visibility = INVISIBLE
}
// show disabled items differently
showEnabled(item.enabled)
}
private fun showEnabled(enabled: Boolean) {
val alpha = if (enabled) 1.0f else 0.5f
// setting the alpha on root view v only doesn't work as the ItemAnimator messes with it
appIcon.alpha = alpha
appName.alpha = alpha
appInfo.alpha = alpha
appStatus.alpha = alpha
}
}
}
data class AppStatus(
val packageName: String,
var enabled: Boolean,
val icon: Drawable,
val name: String,
val time: Long,
val status: AppRestoreStatus)
internal class AppStatusDiff(
private val oldItems: List<AppStatus>,
private val newItems: List<AppStatus>) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition] == newItems[newItemPosition]
}
}
internal class AppStatusResult(
val appStatusList: List<AppStatus>,
val diff: DiffResult
)

View file

@ -0,0 +1,79 @@
package com.stevesoltys.seedvault.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_app_status.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal interface AppStatusToggleListener {
fun onAppStatusToggled(status: AppStatus)
}
class AppStatusFragment : Fragment(), AppStatusToggleListener {
private val viewModel: SettingsViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppStatusAdapter(this)
private lateinit var appEditMenuItem: MenuItem
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true)
return inflater.inflate(R.layout.fragment_app_status, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
activity?.setTitle(R.string.settings_backup_status_title)
list.apply {
layoutManager = this@AppStatusFragment.layoutManager
adapter = this@AppStatusFragment.adapter
}
progressBar.visibility = VISIBLE
viewModel.appStatusList.observe(this, Observer { result ->
adapter.update(result.appStatusList, result.diff)
progressBar.visibility = INVISIBLE
})
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.app_status_menu, menu)
appEditMenuItem = menu.findItem(R.id.edit_app_blacklist)
// observe edit mode changes here where we are sure to have the MenuItem
viewModel.appEditMode.observe(this, Observer { enabled ->
appEditMenuItem.isChecked = enabled
adapter.setEditMode(enabled)
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.edit_app_blacklist -> {
viewModel.setEditMode(!item.isChecked)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onAppStatusToggled(status: AppStatus) {
adapter.onItemChanged(status)
viewModel.onAppStatusToggled(status)
}
}

View file

@ -2,6 +2,9 @@ package com.stevesoltys.seedvault.settings
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
@ -9,7 +12,9 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class SettingsActivity : RequireProvisioningActivity() {
internal const val ACTION_APP_STATUS_LIST = "com.stevesoltys.seedvault.APP_STATUS_LIST"
class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmentCallback {
private val viewModel: SettingsViewModel by viewModel()
private val notificationManager: BackupNotificationManager by inject()
@ -23,7 +28,11 @@ class SettingsActivity : RequireProvisioningActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment())
if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment())
} else if (savedInstanceState == null) {
showFragment(SettingsFragment())
}
}
@CallSuper
@ -41,4 +50,14 @@ class SettingsActivity : RequireProvisioningActivity() {
}
}
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment)
fragment.setTargetFragment(caller, 0)
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, fragment)
.addToBackStack(null)
.commit()
return true
}
}

View file

@ -12,12 +12,12 @@ import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer
import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
@ -26,9 +26,9 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import java.util.*
private val TAG = SettingsFragment::class.java.name
@ -40,7 +40,9 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backup: TwoStatePreference
private lateinit var autoRestore: TwoStatePreference
private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference
private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null
@ -63,7 +65,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
setPreferencesFromResource(R.xml.settings, rootKey)
setHasOptionsMenu(true)
backup = findPreference<TwoStatePreference>("backup")!!
backup = findPreference("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -76,13 +78,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
backupLocation = findPreference<Preference>("backup_location")!!
backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation()
true
}
autoRestore = findPreference<TwoStatePreference>("auto_restore")!!
autoRestore = findPreference("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -94,6 +96,28 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@OnPreferenceChangeListener false
}
}
apkBackup = findPreference(PREF_KEY_BACKUP_APK)!!
apkBackup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enable = newValue as Boolean
if (enable) return@OnPreferenceChangeListener true
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.settings_backup_apk_dialog_title)
.setMessage(R.string.settings_backup_apk_dialog_message)
.setPositiveButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
dialog.dismiss()
}
.setNegativeButton(R.string.settings_backup_apk_dialog_disable) { dialog, _ ->
apkBackup.isChecked = enable
dialog.dismiss()
}
.show()
return@OnPreferenceChangeListener false
}
backupStatus = findPreference("backup_status")!!
viewModel.lastBackupTime.observe(this, Observer { time -> setAppBackupStatusSummary(time) })
}
override fun onStart() {
@ -103,9 +127,9 @@ class SettingsFragment : PreferenceFragmentCompat() {
activity?.setTitle(R.string.backup)
storage = settingsManager.getStorage()
setBackupState()
setAutoRestoreState()
setBackupEnabledState()
setBackupLocationSummary()
setAutoRestoreState()
setMenuItemStates()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
@ -127,23 +151,23 @@ class SettingsFragment : PreferenceFragmentCompat() {
setMenuItemStates()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == R.id.action_backup -> {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_backup -> {
viewModel.backupNow()
true
}
item.itemId == R.id.action_restore -> {
R.id.action_restore -> {
startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
item.itemId == R.id.action_about -> {
R.id.action_about -> {
AboutDialogFragment().show(fragmentManager!!, AboutDialogFragment.TAG)
true
}
else -> super.onOptionsItemSelected(item)
}
private fun setBackupState() {
private fun setBackupEnabledState() {
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
@ -157,20 +181,24 @@ class SettingsFragment : PreferenceFragmentCompat() {
activity?.contentResolver?.let {
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
}
val storage = this.storage
if (storage?.isUsb == true) {
autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" +
getString(R.string.settings_auto_restore_summary_usb, storage.name)
} else {
autoRestore.setSummary(R.string.settings_auto_restore_summary)
}
}
private fun setBackupLocationSummary() {
// get name of storage location
val storageName = storage?.name ?: getString(R.string.settings_backup_location_none)
// get time of last backup
val lastBackupInMillis = settingsManager.getBackupTime()
val lastBackup = if (lastBackupInMillis == 0L) {
getString(R.string.settings_backup_last_backup_never)
} else {
getRelativeTimeSpanString(lastBackupInMillis, Date().time, MINUTE_IN_MILLIS, 0)
backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none)
}
backupLocation.summary = getString(R.string.settings_backup_location_summary, storageName, lastBackup)
private fun setAppBackupStatusSummary(lastBackupInMillis: Long) {
// set time of last backup
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
}
private fun setMenuItemStates() {

View file

@ -3,9 +3,12 @@ package com.stevesoltys.seedvault.settings
import android.content.Context
import android.hardware.usb.UsbDevice
import android.net.Uri
import androidx.annotation.UiThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -16,13 +19,18 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_TIME = "backupTime"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false)
private val blacklistedApps: HashSet<String> by lazy {
prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()).toHashSet()
}
// FIXME Storage is currently plugin specific and not generic
fun setStorage(storage: Storage) {
prefs.edit()
@ -30,6 +38,7 @@ class SettingsManager(context: Context) {
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
isStorageChanging.set(true)
}
fun getStorage(): Storage? {
@ -40,6 +49,10 @@ class SettingsManager(context: Context) {
return Storage(uri, name, isUsb)
}
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()
@ -66,46 +79,17 @@ class SettingsManager(context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId)
}
/**
* Generates and returns a new backup token while saving it as well.
* Subsequent calls to [getBackupToken] will return this new token once saved.
*/
fun getAndSaveNewBackupToken(): Long = Date().time.apply {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
fun backupApks(): Boolean {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true)
}
/**
* Returns the current backup token or 0 if none exists.
*/
fun getBackupToken(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName)
/**
* Sets the last backup time to "now".
*/
fun saveNewBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, Date().time)
.apply()
}
/**
* Sets the last backup time to "never".
*/
fun resetBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, 0L)
.apply()
}
/**
* Returns the last backup time in unix epoch milli seconds.
*/
fun getBackupTime(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TIME, 0L)
@UiThread
fun onAppBackupStatusChanged(status: AppStatus) {
if (status.enabled) blacklistedApps.remove(status.packageName)
else blacklistedApps.add(status.packageName)
prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply()
}
}

View file

@ -1,20 +1,121 @@
package com.stevesoltys.seedvault.settings
import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import androidx.annotation.UiThread
import androidx.core.content.ContextCompat.getDrawable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.DiffUtil.calculateDiff
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager
keyManager: KeyManager,
private val metadataManager: MetadataManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
override val isRestoreOperation = false
fun backupNow() {
internal val lastBackupTime = metadataManager.lastBackupTime
private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() }
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode
init {
viewModelScope.launch(Dispatchers.IO) {
// ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime()
}
}
internal fun backupNow() {
Thread { requestBackup(app) }.start()
}
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData(Dispatchers.Main) {
val pm = app.packageManager
val locale = Locale.getDefault()
val list = pm.getInstalledPackages(0)
.filter { !it.isSystemApp() }
.map {
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
getDrawable(app, R.drawable.ic_launcher_default)!!
} else {
try {
pm.getApplicationIcon(it.packageName)
} catch (e: NameNotFoundException) {
getDrawable(app, R.drawable.ic_launcher_default)!!
}
}
val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0
val status = when (metadata?.state) {
null -> {
Log.w(TAG, "No metadata available for: ${it.packageName}")
FAILED
}
NO_DATA -> FAILED_NO_DATA
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> SUCCEEDED
}
if (metadata?.hasApk() == false) {
Log.w(TAG, "No APK stored for: ${it.packageName}")
}
AppStatus(
packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName),
icon = icon,
name = getAppName(app, it.packageName).toString(),
time = time,
status = status
)
}.sortedBy { it.name.toLowerCase(locale) }
val oldList = mAppStatusList.value?.appStatusList ?: emptyList()
val diff = calculateDiff(AppStatusDiff(oldList, list))
emit(AppStatusResult(list, diff))
}
@UiThread
fun setEditMode(enabled: Boolean) {
mAppEditMode.value = enabled
}
@UiThread
fun onAppStatusToggled(status: AppStatus) {
settingsManager.onAppBackupStatusChanged(status)
}
}

View file

@ -15,7 +15,7 @@ import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.NotificationBackupObserver
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.backup.PackageService
import org.koin.core.context.GlobalContext.get
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
@ -51,18 +51,16 @@ class ConfigurableBackupTransportService : Service() {
@WorkerThread
fun requestBackup(context: Context) {
// show notification
val nm: BackupNotificationManager = get().koin.get()
nm.onBackupUpdate(context.getString(R.string.notification_backup_starting), 0, 1, true)
val packageService: PackageService = get().koin.get()
val packages = packageService.eligiblePackages
val observer = NotificationBackupObserver(context, true)
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
val packages = PackageService.eligiblePackages
val observer = NotificationBackupObserver(context, packages.size, true)
val result = try {
val backupManager: IBackupManager = get().koin.get()
backupManager.requestBackup(packages, observer, BackupMonitor(), flags)
backupManager.requestBackup(packages, observer, BackupMonitor(), FLAG_USER_INITIATED)
} catch (e: RemoteException) {
Log.e(TAG, "Error during backup: ", e)
val nm: BackupNotificationManager = get().koin.get()
nm.onBackupError()
}
if (result == BackupManager.SUCCESS) {

View file

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

View file

@ -0,0 +1,144 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.metadata.isUpdatedSystemApp
import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
import java.security.MessageDigest
private val TAG = ApkBackup::class.java.simpleName
class ApkBackup(
private val pm: PackageManager,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager) {
/**
* Checks if a new APK needs to get backed up,
* because the version code or the signatures have changed.
* Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter
* and the APK binary written to it.
*
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/
@Throws(IOException::class)
fun backupApkIfNecessary(packageInfo: PackageInfo, packageState: PackageState, streamGetter: () -> OutputStream): PackageMetadata? {
// do not back up @pm@
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return null
// do not back up when setting is not enabled
if (!settingsManager.backupApks()) return null
// do not back up system apps that haven't been updated
if (packageInfo.isSystemApp() && !packageInfo.isUpdatedSystemApp()) {
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
return null
}
// TODO remove when adding support for packages with multiple signers
if (packageInfo.signingInfo.hasMultipleSigners()) {
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
return null
}
// get signatures
val signatures = packageInfo.signingInfo.getSignatures()
if (signatures.isEmpty()) {
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
return null
}
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata()
// get version codes
val version = packageInfo.longVersionCode
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
// do not backup if we have the version already and signatures did not change
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.")
return null
}
// get an InputStream for the APK
val apk = File(packageInfo.applicationInfo.sourceDir)
val inputStream = try {
apk.inputStream()
} catch (e: FileNotFoundException) {
Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e)
throw IOException(e)
} catch (e: SecurityException) {
Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e)
throw IOException(e)
}
// copy the APK to the storage's output and calculate SHA-256 hash while at it
val messageDigest = MessageDigest.getInstance("SHA-256")
streamGetter.invoke().use { outputStream ->
inputStream.use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
}
val sha256 = messageDigest.digest().encodeBase64()
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
// return updated metadata
return PackageMetadata(
state = packageState,
version = version,
installer = pm.getInstallerPackageName(packageName),
sha256 = sha256,
signatures = signatures
)
}
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {
// no signatures in package metadata counts as them not having changed
if (packageMetadata.signatures == null) return false
// TODO to support multiple signers check if lists differ
return packageMetadata.signatures.intersect(signatures).isEmpty()
}
}
/**
* Returns a list of Base64 encoded SHA-256 signature hashes.
*/
fun SigningInfo.getSignatures(): List<String> {
return if (hasMultipleSigners()) {
apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64()
}
} else {
signingCertificateHistory.map { signature ->
hashSignature(signature).encodeBase64()
}
}
}
private fun hashSignature(signature: Signature): ByteArray {
return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
}

View file

@ -1,13 +1,24 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataWriter
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
@ -23,12 +34,16 @@ internal class BackupCoordinator(
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val apkBackup: ApkBackup,
private val clock: Clock,
private val packageService: PackageService,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
private var calledClearBackupData = false
private var cancelReason: PackageState = UNKNOWN_ERROR
// ------------------------------------------------------------------------------------
// Transport initialization and quota
@ -47,7 +62,7 @@ internal class BackupCoordinator(
* 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.
* 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).
@ -55,8 +70,13 @@ internal class BackupCoordinator(
fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
writeBackupMetadata(settingsManager.getBackupToken())
val token = clock.time()
if (plugin.initializeDevice(token)) {
Log.d(TAG, "Resetting backup metadata...")
metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream())
} else {
Log.d(TAG, "Storage was already initialized, doing no-op")
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
@ -70,12 +90,30 @@ internal class BackupCoordinator(
}
fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean {
val packageName = targetPackage.packageName
// Check that the app is not blacklisted by the user
val enabled = settingsManager.isBackupEnabled(packageName)
if (!enabled) Log.w(TAG, "Backup of $packageName disabled by user.")
// 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
return enabled && targetPackage.packageName != plugin.providerPackageName
}
/**
* Ask the transport about current quota for backup size of the package.
*
* @param packageName ID of package to provide the quota.
* @param isFullBackup If set, transport should return limit for full data backup,
* otherwise for key-value backup.
* @return Current limit on backup size in bytes.
*/
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
if (packageName != MAGIC_PACKAGE_MANAGER) {
// try to back up APK here as later methods are sometimes not called called
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
}
// report back quota
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.")
@ -102,15 +140,18 @@ internal class BackupCoordinator(
}
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) {
// backups of package manager metadata do not respect backoff
// we need to reject them manually when now is not a good time for a backup
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
if (getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
// hook in here to back up APKs of apps that are otherwise not allowed for backup
backUpNotAllowedPackages()
}
return kv.performBackup(packageInfo, data, flags)
}
// ------------------------------------------------------------------------------------
@ -134,17 +175,40 @@ internal class BackupCoordinator(
Log.i(TAG, "Request full backup time. Returned $this")
}
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size)
if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED
return result
}
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags)
}
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
fun cancelFullBackup() = full.cancelFullBackup()
/**
* Tells the transport to cancel the currently-ongoing full backup operation.
* This will happen between [performFullBackup] and [finishBackup]
* if the OS needs to abort the backup operation for any reason,
* such as a crash in the application undergoing backup.
*
* When it receives this call,
* the transport should discard any partial archive that it has stored so far.
* If possible it should also roll back to the previous known-good archive in its data store.
*
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
* It needs to tear down any ongoing backup state here.
*/
fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
onPackageBackupError(packageInfo)
full.cancelFullBackup()
}
// Clear and Finish
@ -176,13 +240,23 @@ internal class BackupCoordinator(
return TRANSPORT_OK
}
/**
* Finish sending application data to the backup destination.
* This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* to ensure that all data is sent and the operation properly finalized.
* Only when this method returns true can a backup be assumed to have succeeded.
*
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/
fun finishBackup(): Int = when {
kv.hasState() -> {
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state
kv.finishBackup()
}
full.hasState() -> {
check(!kv.hasState()) { "Full backup has state, but K/V backup has dangling state as well" }
onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
@ -193,10 +267,51 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()")
}
@Throws(IOException::class)
private fun writeBackupMetadata(token: Long) {
private fun backUpNotAllowedPackages() {
Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
packageService.notAllowedPackages.forEach { optOutPackageInfo ->
try {
backUpApk(optOutPackageInfo, NOT_ALLOWED)
} catch (e: IOException) {
Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e)
}
}
}
private fun backUpApk(packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR) {
val packageName = packageInfo.packageName
try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream()
metadataWriter.write(outputStream, token)
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream)
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
}
}
private fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackedUp(packageInfo, outputStream)
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
}
}
private fun onPackageBackupError(packageInfo: PackageInfo) {
// don't bother with system apps that have no data
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackupError(packageInfo, cancelReason, outputStream)
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
}
}
private fun getBackupBackoff(): Long {

View file

@ -5,7 +5,9 @@ import org.koin.dsl.module
val backupModule = module {
single { InputFactory() }
single { PackageService(androidContext().packageManager, get()) }
single { ApkBackup(androidContext().packageManager, get(), get()) }
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.OutputStream
@ -11,9 +12,12 @@ interface BackupPlugin {
/**
* Initialize the storage for this device, erasing all stored data.
*
* @return true if the device needs initialization or
* false if the device was initialized already and initialization should be a no-op.
*/
@Throws(IOException::class)
fun initializeDevice()
fun initializeDevice(newToken: Long): Boolean
/**
* Returns an [OutputStream] for writing backup metadata.
@ -21,6 +25,12 @@ interface BackupPlugin {
@Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream
/**
* Returns an [OutputStream] for writing an APK to be backed up.
*/
@Throws(IOException::class)
fun getApkOutputStream(packageInfo: PackageInfo): OutputStream
/**
* Returns the package name of the app that provides the backend storage
* which is used for the current backup location.

View file

@ -1,6 +1,10 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.FLAG_USER_INITIATED
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
@ -17,7 +21,8 @@ private class FullBackupState(
internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream,
internal val outputStream: OutputStream) {
internal var outputStreamInit: (() -> OutputStream)?) {
internal var outputStream: OutputStream? = null
internal val packageName: String = packageInfo.packageName
internal var size: Long = 0
}
@ -36,13 +41,15 @@ internal class FullBackup(
fun hasState() = state != null
fun getCurrentPackage() = state?.packageInfo
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
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
else -> TRANSPORT_OK
}
}
@ -86,42 +93,32 @@ internal class FullBackup(
if (state != null) throw AssertionError()
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
// create new state
val inputStream = inputFactory.getInputStream(socket)
state = FullBackupState(targetPackage, socket, inputStream) {
Log.d(TAG, "Initializing OutputStream 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)
throw(e)
}
// 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)
headerWriter.writeVersion(outputStream, header)
crypto.encryptHeader(outputStream, header)
} catch (e: IOException) {
Log.e(TAG, "Error writing backup header", e)
return backupError(TRANSPORT_ERROR)
throw(e)
}
outputStream
} // this lambda is only called before we actually write backup data the first time
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")
@ -134,11 +131,19 @@ internal class FullBackup(
return TRANSPORT_QUOTA_EXCEEDED
}
Log.i(TAG, "Send full backup data of $numBytes bytes.")
return try {
// get output stream or initialize it, if it does not yet exist
check((state.outputStream != null) xor (state.outputStreamInit != null)) { "No OutputStream xor no StreamGetter" }
val outputStream = state.outputStream ?: {
val stream = state.outputStreamInit!!.invoke() // not-null due to check above
state.outputStream = stream
stream
}.invoke()
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
// read backup data, encrypt it and write it to output stream
val payload = IOUtils.readFully(state.inputStream, numBytes)
crypto.encryptSegment(state.outputStream, payload)
crypto.encryptSegment(outputStream, payload)
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
@ -146,6 +151,7 @@ internal class FullBackup(
}
}
@Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
@ -153,12 +159,12 @@ internal class FullBackup(
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)
}
clearState()
// TODO roll back to the previous known-good archive
}
@ -170,7 +176,7 @@ internal class FullBackup(
private fun clearState(): Int {
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
return try {
state.outputStream.flush()
state.outputStream?.flush()
closeQuietly(state.outputStream)
closeQuietly(state.inputStream)
closeQuietly(state.inputFileDescriptor)

View file

@ -1,6 +1,10 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.FLAG_INCREMENTAL
import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
@ -11,7 +15,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
class KVBackupState(internal val packageName: String)
class KVBackupState(internal val packageInfo: PackageInfo)
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
@ -27,6 +31,8 @@ internal class KVBackup(
fun hasState() = state != null
fun getCurrentPackage() = state?.packageInfo
fun getQuota(): Long = plugin.getQuota()
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
@ -48,7 +54,7 @@ internal class KVBackup(
// initialize state
if (this.state != null) throw AssertionError()
this.state = KVBackupState(packageInfo.packageName)
this.state = KVBackupState(packageInfo)
// check if we have existing data for the given package
val hasDataForPackage = try {
@ -162,7 +168,7 @@ internal class KVBackup(
}
fun finishBackup(): Int {
Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}")
Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}")
state = null
return TRANSPORT_OK
}
@ -172,7 +178,7 @@ internal class KVBackup(
* 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}")
Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}")
state = null
return result
}

View file

@ -0,0 +1,76 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.IBackupManager
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
import android.util.Log.INFO
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
private val TAG = PackageService::class.java.simpleName
private const val LOG_MAX_PACKAGES = 100
/**
* @author Steve Soltys
* @author Torsten Grote
*/
internal class PackageService(
private val packageManager: PackageManager,
private val backupManager: IBackupManager) {
private val myUserId = UserHandle.myUserId()
val eligiblePackages: Array<String>
@WorkerThread
@Throws(RemoteException::class)
get() {
val packages = packageManager.getInstalledPackages(0)
.map { packageInfo -> packageInfo.packageName }
.sorted()
// log packages
if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Got ${packages.size} packages:")
packages.chunked(LOG_MAX_PACKAGES).forEach {
Log.i(TAG, it.toString())
}
}
val eligibleApps = backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
// log eligible packages
if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
eligibleApps.toList().chunked(LOG_MAX_PACKAGES).forEach {
Log.i(TAG, it.toString())
}
}
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
val packageArray = eligibleApps.toMutableList()
packageArray.add(MAGIC_PACKAGE_MANAGER)
return packageArray.toTypedArray()
}
val notAllowedPackages: List<PackageInfo>
@WorkerThread
get() {
val installed = packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
val installedArray = installed.map { packageInfo ->
packageInfo.packageName
}.toTypedArray()
val eligible = backupManager.filterAppsEligibleForBackupForUser(myUserId, installedArray)
return installed.filter { packageInfo ->
packageInfo.packageName !in eligible
}.sortedBy { it.packageName }
}
}

View file

@ -0,0 +1,99 @@
package com.stevesoltys.seedvault.transport.restore
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_RECEIVER_FOREGROUND
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.EXTRA_PACKAGE_NAME
import android.content.pm.PackageInstaller.EXTRA_STATUS
import android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
import android.content.pm.PackageInstaller.STATUS_SUCCESS
import android.content.pm.PackageInstaller.SessionParams
import android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
import android.content.pm.PackageManager
import android.util.Log
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import java.io.File
import java.io.IOException
private val TAG: String = ApkInstaller::class.java.simpleName
private const val BROADCAST_ACTION = "com.android.packageinstaller.ACTION_INSTALL_COMMIT"
internal class ApkInstaller(private val context: Context) {
private val pm: PackageManager = context.packageManager
private val installer: PackageInstaller = pm.packageInstaller
@ExperimentalCoroutinesApi
@Throws(IOException::class, SecurityException::class)
internal fun install(cachedApk: File, packageName: String, installerPackageName: String?, installResult: MutableInstallResult) = callbackFlow {
val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return
offer(onBroadcastReceived(i, packageName, cachedApk, installResult))
close()
}
}
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
install(cachedApk, installerPackageName)
awaitClose { context.unregisterReceiver(broadcastReceiver) }
}
private fun install(cachedApk: File, installerPackageName: String?) {
val sessionParams = SessionParams(MODE_FULL_INSTALL).apply {
setInstallerPackageName(installerPackageName)
}
// Don't set more sessionParams intentionally here.
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
val session = installer.openSession(installer.createSession(sessionParams))
val sizeBytes = cachedApk.length()
session.use { s ->
cachedApk.inputStream().use { inputStream ->
s.openWrite("PackageInstaller", 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
}
}
s.commit(getIntentSender())
}
}
private fun getIntentSender(): IntentSender {
val broadcastIntent = Intent(BROADCAST_ACTION).apply {
flags = FLAG_RECEIVER_FOREGROUND
setPackage(context.packageName)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, broadcastIntent, FLAG_UPDATE_CURRENT)
return pendingIntent.intentSender
}
private fun onBroadcastReceived(i: Intent, expectedPackageName: String, cachedApk: File, installResult: MutableInstallResult): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS
val statusMsg = i.getStringExtra(EXTRA_STATUS_MESSAGE)!!
check(packageName == expectedPackageName) { "Expected $expectedPackageName, but got $packageName." }
Log.d(TAG, "Received result for $packageName: success=$success $statusMsg")
// delete cached APK file
cachedApk.delete()
// update status and offer result
val status = if (success) SUCCEEDED else FAILED
return installResult.update(packageName) { it.copy(status = status) }
}
}

View file

@ -0,0 +1,190 @@
package com.stevesoltys.seedvault.transport.restore
import android.content.Context
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import android.util.Log
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.transport.backup.getSignatures
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore(
private val context: Context,
private val restorePlugin: RestorePlugin,
private val apkInstaller: ApkInstaller = ApkInstaller(context)) {
private val pm = context.packageManager
@ExperimentalCoroutinesApi
fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow {
// filter out packages without APK and get total
val packages = packageMetadataMap.filter { it.value.hasApk() }
val total = packages.size
var progress = 0
// queue all packages and emit LiveData
val installResult = MutableInstallResult(total)
packages.forEach { (packageName, _) ->
progress++
installResult[packageName] = ApkRestoreResult(packageName, progress, total, QUEUED)
}
emit(installResult)
// restore individual packages and emit updates
for ((packageName, metadata) in packages) {
try {
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
restore(token, packageName, metadata, installResult).collect {
emit(it)
}
} catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
} catch (e: SecurityException) {
Log.e(TAG, "Security error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
}
}
}
@ExperimentalCoroutinesApi
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Throws(IOException::class, SecurityException::class)
private fun restore(token: Long, packageName: String, metadata: PackageMetadata, installResult: MutableInstallResult) = flow {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
val messageDigest = MessageDigest.getInstance("SHA-256")
restorePlugin.getApkInputStream(token, packageName).use { inputStream ->
cachedApk.outputStream().use { outputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
}
// check APK's SHA-256 hash
val sha256 = messageDigest.digest().encodeBase64()
if (metadata.sha256 != sha256) {
throw SecurityException("Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected.")
}
// parse APK (GET_SIGNATURES is needed even though deprecated)
@Suppress("DEPRECATION") val flags = GET_SIGNING_CERTIFICATES or GET_SIGNATURES
val packageInfo = pm.getPackageArchiveInfo(cachedApk.absolutePath, flags)
?: throw IOException("getPackageArchiveInfo returned null")
// check APK package name
if (packageName != packageInfo.packageName) {
throw SecurityException("Package $packageName expected, but ${packageInfo.packageName} found.")
}
// check APK version code
if (metadata.version != packageInfo.longVersionCode) {
Log.w(TAG, "Package $packageName expects version code ${metadata.version}, but has ${packageInfo.longVersionCode}.")
// TODO should we let this one pass, maybe once we can revert PackageMetadata during backup?
}
// check signatures
if (metadata.signatures != packageInfo.signingInfo.getSignatures()) {
Log.w(TAG, "Package $packageName expects different signatures.")
// TODO should we let this one pass, the sha256 hash already verifies the APK?
}
// get app icon and label (name)
val appInfo = packageInfo.applicationInfo.apply {
// set APK paths before, so package manager can find it for icon extraction
sourceDir = cachedApk.absolutePath
publicSourceDir = cachedApk.absolutePath
}
val icon = appInfo.loadIcon(pm)
val name = pm.getApplicationLabel(appInfo) ?: packageName
installResult.update(packageName) { it.copy(status = IN_PROGRESS, name = name, icon = icon) }
emit(installResult)
// ensure system apps are actually installed and newer system apps as well
if (metadata.system) {
try {
val installedPackageInfo = pm.getPackageInfo(packageName, 0)
// metadata.version is not null, because here hasApk() must be true
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
} catch (e: NameNotFoundException) {
Log.w(TAG, "Not installing $packageName because older or not a system app here.")
fail(installResult, packageName)
return@flow
}
}
// install APK and emit updates from it
apkInstaller.install(cachedApk, packageName, metadata.installer, installResult).collect { result ->
emit(result)
}
}
private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult {
return installResult.update(packageName) { it.copy(status = FAILED) }
}
}
internal typealias InstallResult = Map<String, ApkRestoreResult>
internal fun InstallResult.getInProgress(): ApkRestoreResult? {
val filtered = filterValues { result -> result.status == IN_PROGRESS }
if (filtered.isEmpty()) return null
check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" }
return filtered.values.first()
}
internal class MutableInstallResult(initialCapacity: Int) : ConcurrentHashMap<String, ApkRestoreResult>(initialCapacity) {
fun update(packageName: String, updateFun: (ApkRestoreResult) -> ApkRestoreResult): MutableInstallResult {
val result = get(packageName)
check(result != null) { "ApkRestoreResult for $packageName does not exist." }
set(packageName, updateFun(result))
return this
}
}
internal data class ApkRestoreResult(
val packageName: CharSequence,
val progress: Int,
val total: Int,
val status: ApkRestoreStatus,
val name: CharSequence? = null,
val icon: Drawable? = null
) : Comparable<ApkRestoreResult> {
override fun compareTo(other: ApkRestoreResult): Int {
return other.progress.compareTo(progress)
}
}
internal enum class ApkRestoreStatus {
QUEUED, IN_PROGRESS, SUCCEEDED, FAILED
}

View file

@ -1,6 +1,9 @@
package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.NO_MORE_DATA
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
@ -76,7 +79,6 @@ internal class FullRestore(
* 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("no state")
val packageName = state.packageInfo.packageName

View file

@ -6,6 +6,9 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.decodeBase64
import com.stevesoltys.seedvault.header.HeaderReader
@ -17,7 +20,11 @@ import javax.crypto.AEADBadTagException
private class KVRestoreState(
internal val token: Long,
internal val packageInfo: PackageInfo)
internal val packageInfo: PackageInfo,
/**
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
*/
internal val pmPackageInfo: PackageInfo?)
private val TAG = KVRestore::class.java.simpleName
@ -42,9 +49,11 @@ internal class KVRestore(
*
* 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.
*
* @param pmPackageInfo single optional [PackageInfo] to optimize restore of @pm@
*/
fun initializeState(token: Long, packageInfo: PackageInfo) {
state = KVRestoreState(token, packageInfo)
fun initializeState(token: Long, packageInfo: PackageInfo, pmPackageInfo: PackageInfo? = null) {
state = KVRestoreState(token, packageInfo, pmPackageInfo)
}
/**
@ -111,8 +120,15 @@ internal class KVRestore(
// 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
// remove keys that are not needed for single package @pm@ restore
val pmPackageName = state?.pmPackageInfo?.packageName
val sortedKeys = if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
contents.filterTo(ArrayList()) { it.key in keys }
} else contents
sortedKeys.sort()
return sortedKeys
}
/**

View file

@ -2,14 +2,24 @@ package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.IBackupManager
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreDescription.NO_MORE_PACKAGES
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.app.backup.RestoreSet
import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager
import libcore.io.IoUtils.closeQuietly
@ -17,18 +27,29 @@ import java.io.IOException
private class RestoreCoordinatorState(
internal val token: Long,
internal val packages: Iterator<PackageInfo>)
internal val packages: Iterator<PackageInfo>,
/**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/
internal val pmPackageInfo: PackageInfo?) {
internal var currentPackage: String? = null
}
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val context: Context,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val notificationManager: BackupNotificationManager,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore,
private val metadataReader: MetadataReader) {
private var state: RestoreCoordinatorState? = null
private var backupMetadata: LongSparseArray<BackupMetadata>? = null
private val failedPackages = ArrayList<String>()
/**
* Get the set of all backups currently available over this transport.
@ -39,6 +60,7 @@ internal class RestoreCoordinator(
fun getAvailableRestoreSets(): Array<RestoreSet>? {
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>()
val metadataMap = LongSparseArray<BackupMetadata>()
for (encryptedMetadata in availableBackups) {
if (encryptedMetadata.error) continue
check(encryptedMetadata.inputStream != null) {
@ -46,6 +68,7 @@ internal class RestoreCoordinator(
}
try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
metadataMap.put(encryptedMetadata.token, metadata)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
restoreSets.add(set)
} catch (e: IOException) {
@ -65,6 +88,7 @@ internal class RestoreCoordinator(
}
}
Log.i(TAG, "Got available restore sets: $restoreSets")
this.backupMetadata = metadataMap
return restoreSets.toTypedArray()
}
@ -76,7 +100,7 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long {
return settingsManager.getBackupToken()
return metadataManager.getBackupToken()
.apply { Log.i(TAG, "Got current restore set token: $this") }
}
@ -95,7 +119,27 @@ internal class RestoreCoordinator(
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
check(state == null) { "Started new restore with existing state" }
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
state = RestoreCoordinatorState(token, packages.iterator())
// If there's only one package to restore (Auto Restore feature), add it to the state
val pmPackageInfo = if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
val pmPackageName = packages[1].packageName
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
// check if the backup is on removable storage that is not plugged in
if (isStorageRemovableAndNotAvailable()) {
// check if we even have a backup of that app
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
// remind user to plug in storage device
val storageName = settingsManager.getStorage()?.name
?: context.getString(R.string.settings_backup_location_none)
notificationManager.onRemovableStorageNotAvailableForRestore(pmPackageName, storageName)
}
return TRANSPORT_ERROR
}
packages[1]
} else null
state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo)
failedPackages.clear()
return TRANSPORT_OK
}
@ -138,12 +182,14 @@ internal class RestoreCoordinator(
// 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)
kv.initializeState(state.token, packageInfo, state.pmPackageInfo)
state.currentPackage = packageName
TYPE_KEY_VALUE
}
full.hasDataForPackage(state.token, packageInfo) -> {
Log.i(TAG, "Found full backup data for $packageName.")
full.initializeState(state.token, packageInfo)
state.currentPackage = packageName
TYPE_FULL_STREAM
}
else -> {
@ -153,6 +199,7 @@ internal class RestoreCoordinator(
}
} catch (e: IOException) {
Log.e(TAG, "Error finding restore data for $packageName.", e)
failedPackages.add(packageName)
return null
}
return RestoreDescription(packageName, type)
@ -161,13 +208,18 @@ internal class RestoreCoordinator(
/**
* 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.
* 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)
return kv.getRestoreData(data).apply {
if (this != TRANSPORT_OK) {
// add current package to failed ones
state?.currentPackage?.let { failedPackages.add(it) }
}
}
}
/**
@ -188,6 +240,7 @@ internal class RestoreCoordinator(
* or will call [finishRestore] to shut down the restore operation.
*/
fun abortFullRestore(): Int {
state?.currentPackage?.let { failedPackages.add(it) }
return full.abortFullRestore()
}
@ -199,4 +252,24 @@ internal class RestoreCoordinator(
if (full.hasState()) full.finishRestore()
}
/**
* Call this after calling [IBackupManager.getAvailableRestoreTokenForUser]
* to retrieve additional [BackupMetadata] that is not available in [RestoreSet].
*
* It will also clear the saved metadata, so that subsequent calls will return null.
*/
fun getAndClearBackupMetadata(): LongSparseArray<BackupMetadata>? {
val result = backupMetadata
backupMetadata = null
return result
}
fun isFailedPackage(packageName: String) = packageName in failedPackages
// TODO this is plugin specific, needs to be factored out when supporting different plugins
private fun isStorageRemovableAndNotAvailable(): Boolean {
val storage = settingsManager.getStorage() ?: return false
return storage.isUsb && !storage.getDocumentFile(context).isDirectory
}
}

View file

@ -1,10 +1,12 @@
package com.stevesoltys.seedvault.transport.restore
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val restoreModule = module {
single { OutputFactory() }
factory { ApkRestore(androidContext(), get()) }
single { KVRestore(get<RestorePlugin>().kvRestorePlugin, get(), get(), get()) }
single { FullRestore(get<RestorePlugin>().fullRestorePlugin, get(), get(), get()) }
single { RestoreCoordinator(get(), get(), get(), get(), get()) }
single { RestoreCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -3,6 +3,8 @@ package com.stevesoltys.seedvault.transport.restore
import android.net.Uri
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import java.io.IOException
import java.io.InputStream
interface RestorePlugin {
@ -27,4 +29,10 @@ interface RestorePlugin {
@WorkerThread
fun hasBackup(uri: Uri): Boolean
/**
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/
@Throws(IOException::class)
fun getApkInputStream(token: Long, packageName: String): InputStream
}

View file

@ -0,0 +1,73 @@
package com.stevesoltys.seedvault.ui
import android.content.Context
import android.content.pm.PackageManager
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Switch
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.AppRestoreStatus
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) {
protected val context: Context = v.context
protected val pm: PackageManager = context.packageManager
protected val clickableBackground = v.background!!
protected val appIcon: ImageView = v.findViewById(R.id.appIcon)
protected val appName: TextView = v.findViewById(R.id.appName)
protected val appInfo: TextView = v.findViewById(R.id.appInfo)
protected val appStatus: ImageView = v.findViewById(R.id.appStatus)
protected val progressBar: ProgressBar = v.findViewById(R.id.progressBar)
protected val switchView: Switch = v.findViewById(R.id.switchView)
init {
// don't use clickable background by default
v.background = null
}
protected fun setStatus(status: AppRestoreStatus) {
v.background = null
if (status == IN_PROGRESS) {
appInfo.visibility = GONE
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
} else {
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
appInfo.visibility = GONE
when (status) {
SUCCEEDED -> appStatus.setImageResource(R.drawable.ic_check_green)
FAILED -> appStatus.setImageResource(R.drawable.ic_error_red)
else -> {
appStatus.setImageResource(R.drawable.ic_warning_yellow)
appInfo.text = status.getInfo()
appInfo.visibility = VISIBLE
}
}
}
}
private fun AppRestoreStatus.getInfo(): String = when (this) {
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)
else -> "Please report a bug after you read this."
}
}

View file

@ -1,7 +1,7 @@
package com.stevesoltys.seedvault.ui
import android.view.MenuItem
import android.view.View
import android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
@ -10,8 +10,8 @@ import com.stevesoltys.seedvault.R
abstract class BackupActivity : AppCompatActivity() {
@CallSuper
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == android.R.id.home -> {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
onBackPressed()
true
}
@ -26,9 +26,7 @@ abstract class BackupActivity : AppCompatActivity() {
}
protected fun hideSystemUI() {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
window.decorView.systemUiVisibility = SYSTEM_UI_FLAG_HIDE_NAVIGATION
}
}

View file

@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.ui.storage.StorageViewModel
abstract class RequireProvisioningViewModel(
protected val app: Application,
private val settingsManager: SettingsManager,
protected val settingsManager: SettingsManager,
private val keyManager: KeyManager
) : AndroidViewModel(app) {

View file

@ -0,0 +1,15 @@
package com.stevesoltys.seedvault.ui
import android.content.Context
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import com.stevesoltys.seedvault.R
fun Long.toRelativeTime(context: Context): CharSequence {
return if (this == 0L) {
context.getString(R.string.settings_backup_last_backup_never)
} else {
val now = System.currentTimeMillis()
getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, 0)
}
}

View file

@ -38,8 +38,8 @@ class RecoveryCodeActivity : BackupActivity() {
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == android.R.id.home -> {
return when (item.itemId) {
android.R.id.home -> {
onBackPressed()
true
}

View file

@ -5,11 +5,15 @@ import com.stevesoltys.seedvault.App
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import io.github.novacrypto.bip39.*
import io.github.novacrypto.bip39.JavaxPBKDF2WithHmacSHA512
import io.github.novacrypto.bip39.MnemonicGenerator
import io.github.novacrypto.bip39.MnemonicValidator
import io.github.novacrypto.bip39.SeedCalculator
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
import io.github.novacrypto.bip39.Validation.UnexpectedWhiteSpaceException
import io.github.novacrypto.bip39.Validation.WordNotFoundException
import io.github.novacrypto.bip39.Words
import io.github.novacrypto.bip39.wordlists.English
import java.security.SecureRandom
import java.util.*

View file

@ -25,10 +25,7 @@ internal class BackupStorageViewModel(
override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(uri)
// use a new backup token
settingsManager.getAndSaveNewBackupToken()
// initialize the new location
// initialize the new location, will also generate a new backup token
val observer = InitializationObserver()
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)

View file

@ -4,8 +4,8 @@ import android.app.Application
import android.net.Uri
import android.util.Log
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
private val TAG = RestoreStorageViewModel::class.java.simpleName

View file

@ -14,7 +14,16 @@ import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.provider.DocumentsContract.PROVIDER_INTERFACE
import android.provider.DocumentsContract.Root.*
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Root.COLUMN_FLAGS
import android.provider.DocumentsContract.Root.COLUMN_ICON
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.provider.DocumentsContract.Root.COLUMN_SUMMARY
import android.provider.DocumentsContract.Root.COLUMN_TITLE
import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
import android.util.Log
import com.stevesoltys.seedvault.R
import java.lang.Long.parseLong

View file

@ -1,7 +1,9 @@
package com.stevesoltys.seedvault.ui.storage
import android.content.Intent
import android.content.Intent.*
import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View

View file

@ -18,7 +18,6 @@ import com.stevesoltys.seedvault.settings.BackupManagerSettings
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
@ -96,9 +95,6 @@ internal abstract class StorageViewModel(
val storage = Storage(uri, name, root.isUsb)
settingsManager.setStorage(storage)
// reset time of last backup to "Never"
settingsManager.resetBackupTime()
if (storage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice()
@ -110,9 +106,6 @@ internal abstract class StorageViewModel(
BackupManagerSettings.enableAutomaticBackups(app.contentResolver)
}
// 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 saved: $uri")
return storage.isUsb

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.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/green"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>

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.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM10,17l-3.5,-3.5 1.41,-1.41L10,14.17 15.18,9l1.41,1.41L10,17z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/red"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_default_background" />
<foreground android:drawable="@drawable/ic_launcher_default_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeWidth="1"
android:strokeColor="#00000000">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/yellow"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
</vector>

View file

@ -88,6 +88,19 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/licenseView" />
<TextView
android:id="@+id/designView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/about_design"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/authorView" />
<TextView
android:id="@+id/sponsorView"
android:layout_width="wrap_content"
@ -99,7 +112,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/authorView" />
app:layout_constraintTop_toBottomOf="@+id/designView" />
<TextView
android:id="@+id/sourceCodeView"
@ -109,10 +122,10 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:autoLink="web"
android:text="@string/about_source_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:autoLink="web"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sponsorView"

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -5,6 +5,19 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="4dp"
android:indeterminate="false"
android:padding="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:max="23"
tools:progress="5" />
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
@ -13,7 +26,7 @@
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
app:srcCompat="@drawable/ic_cloud_download"
tools:ignore="ContentDescription" />
@ -22,7 +35,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_restoring"
android:text="@string/restore_installing_packages"
android:textColor="?android:textColorSecondary"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
@ -41,60 +54,31 @@
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Pixel 2 XL" />
<TextView
android:id="@+id/currentPackageView"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:textColor="?android:textColorSecondary"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:text="@string/restore_current_package" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
<TextView
android:id="@+id/warningView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:textSize="18sp"
android:text="@string/restore_finished_warning_only_installed"
android:textColor="@android:color/holo_red_dark"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
tools:visibility="visible" />
tools:listitem="@layout/list_item_app_status" />
<Button
android:id="@+id/button"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/restore_finished_button"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
android:enabled="false"
android:text="@string/restore_next"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warningView"
app:layout_constraintVertical_bias="1.0"
tools:visibility="visible" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/appIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/appName"
style="@style/TextAppearance.AppCompat.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toTopOf="@+id/appInfo"
app:layout_constraintEnd_toStartOf="@+id/switchView"
app:layout_constraintStart_toEndOf="@+id/appIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Seedvault Backup" />
<TextView
android:id="@+id/appInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/appName"
app:layout_constraintStart_toStartOf="@+id/appName"
app:layout_constraintTop_toBottomOf="@+id/appName"
tools:text="Some additional information about why the app could not be installed or its data not restored."
tools:visibility="visible" />
<ImageView
android:id="@+id/appStatus"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:srcCompat="@tools:sample/avatars" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Switch
android:id="@+id/switchView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/edit_app_blacklist"
android:checkable="true"
android:title="@string/settings_backup_exclude_apps"
app:showAsAction="never" />
</menu>

View file

@ -2,4 +2,7 @@
<resources>
<color name="accent">#99cc00</color>
<color name="divider">#8A000000</color>
<color name="green">#558B2F</color>
<color name="red">#D32F2F</color>
<color name="yellow">#F9A825</color>
</resources>

View file

@ -14,10 +14,20 @@
<string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal Storage</string>
<string name="settings_backup_last_backup_never">Never</string>
<string name="settings_backup_location_summary">%s · Last Backup %s</string>
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
<string name="settings_auto_restore_title">Automatic restore</string>
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data</string>
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data.</string>
<string name="settings_auto_restore_summary_usb">Note: Your %1$s needs to be plugged in for this to work.</string>
<string name="settings_category_app_data_backup">App data backup</string>
<string name="settings_backup_apk_title">App backup</string>
<string name="settings_backup_apk_summary">Back up the apps themselves. Otherwise, only app data would get backed up.</string>
<string name="settings_backup_apk_dialog_title">Really disable app backup?</string>
<string name="settings_backup_apk_dialog_message">Disabled app backup will still back up app data. However, it will not get restored automatically.\n\nYou will need to install all your apps manually while having \"Automatic Restore\" switched on.</string>
<string name="settings_backup_apk_dialog_cancel">Cancel</string>
<string name="settings_backup_apk_dialog_disable">Disable app backup</string>
<string name="settings_backup_status_title">App backup status</string>
<string name="settings_backup_status_summary">Last Backup: %1$s</string>
<string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</string>
<!-- Storage -->
@ -63,30 +73,43 @@
<!-- Notification -->
<string name="notification_channel_title">Backup Notification</string>
<string name="notification_title">Backup running</string>
<string name="notification_backup_starting">Starting Backup…</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_success_title">Backup finished</string>
<string name="notification_success_num_not_backed_up">%1$d apps could not get backed up</string>
<string name="notification_failed_title">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>
<string name="notification_restore_error_channel_title">Auto Restore Flash Drive Error</string>
<string name="notification_restore_error_title">Could not restore data for %1$s</string>
<string name="notification_restore_error_text">Plug in your %1$s before installing the app to restore its data from backup.</string>
<string name="notification_restore_error_action">Uninstall App</string>
<!-- Restore -->
<string name="restore_title">Restore from Backup</string>
<string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_restore_set_times">Last Backup %1$s · First %2$s.</string>
<string name="restore_back">Don\'t restore</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_installing_packages">Re-installing Apps</string>
<string name="restore_next">Next</string>
<string name="restore_restoring">Restoring Backup</string>
<string name="restore_current_package">Restoring %s…</string>
<string name="restore_finished_success">Restore complete.</string>
<string name="restore_magic_package">System Package Manager</string>
<string name="restore_app_no_data">App reported no data for backup</string>
<string name="restore_app_not_allowed">App doesn\'t allow backup</string>
<string name="restore_app_not_installed">App not installed</string>
<string name="restore_app_quota_exceeded">Backup quota exceeded</string>
<string name="restore_finished_success">Restore complete</string>
<string name="restore_finished_error">An error occurred while restoring the backup.</string>
<string name="restore_finished_warning_only_installed">Note that we could only restore data for apps that are already installed.\n\nWhen you install more apps, we will try to restore their data and settings from this backup. So please do not delete it as long as it might still be needed.%s</string>
<string name="restore_finished_warning_ejectable">\n\nPlease also ensure that the storage medium is plugged in when re-installing your apps.</string>
<string name="restore_finished_button">Finish</string>
<string name="storage_internal_warning_title">Warning</string>
<string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>
@ -98,6 +121,7 @@
<string name="about_summary">A backup application using Android\'s internal backup API.</string>
<string name="about_license">License: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
<string name="about_author">Written by: <a href="https://github.com/stevesoltys">Steve Soltys</a> and <a href="https://blog.grobox.de">Torsten Grote</a></string>
<string name="about_design">Design by: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
<string name="about_sponsor">Sponsored by: <a href="https://www.calyxinstitute.org">Calyx Institute</a> for use in <a href="https://calyxos.org">CalyxOS</a></string>
<string name="about_source_code">Source Code: https://github.com/stevesoltys/seedvault</string>

View file

@ -1,4 +1,5 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.preference.SwitchPreferenceCompat
app:icon="@drawable/ic_cloud_upload"
@ -20,6 +21,26 @@
app:summary="@string/settings_auto_restore_summary"
app:title="@string/settings_auto_restore_title" />
<androidx.preference.PreferenceCategory
app:key="category_app_data_backup"
app:title="@string/settings_category_app_data_backup">
<androidx.preference.Preference
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
app:icon="@drawable/ic_apps"
app:key="backup_status"
app:title="@string/settings_backup_status_title"
tools:summary="Last backup: Never" />
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="backup"
app:key="backup_apk"
app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" />
</androidx.preference.PreferenceCategory>
<androidx.preference.Preference
app:allowDividerAbove="true"
app:allowDividerBelow="false"

View file

@ -3,13 +3,33 @@ package com.stevesoltys.seedvault.crypto
import com.stevesoltys.seedvault.assertContains
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.*
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.HeaderWriter
import com.stevesoltys.seedvault.header.IV_SIZE
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.seedvault.header.MAX_PACKAGE_LENGTH_SIZE
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
import com.stevesoltys.seedvault.header.SegmentHeader
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.fail
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 java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import javax.crypto.Cipher
import kotlin.random.Random

View file

@ -3,7 +3,9 @@ package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.assertContains
import com.stevesoltys.seedvault.getRandomString
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS

View file

@ -2,7 +2,9 @@ package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS

View file

@ -0,0 +1,275 @@
package com.stevesoltys.seedvault.metadata
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.PackageInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.stopKoin
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class MetadataManagerTest {
private val context: Context = mockk()
private val clock: Clock = mockk()
private val metadataWriter: MetadataWriter = mockk()
private val metadataReader: MetadataReader = mockk()
private val manager = MetadataManager(context, clock, metadataWriter, metadataReader)
private val time = 42L
private val token = Random.nextLong()
private val packageName = getRandomString()
private val packageInfo = PackageInfo().apply {
packageName = this@MetadataManagerTest.packageName
applicationInfo = ApplicationInfo().apply { flags = FLAG_ALLOW_BACKUP }
}
private val initialMetadata = BackupMetadata(token = token)
private val storageOutputStream = ByteArrayOutputStream()
private val cacheOutputStream: FileOutputStream = mockk()
private val cacheInputStream: FileInputStream = mockk()
private val encodedMetadata = getRandomByteArray()
@After
fun afterEachTest() {
stopKoin()
}
@Test
fun `test onDeviceInitialization()`() {
every { clock.time() } returns time
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onDeviceInitialization(token, storageOutputStream)
assertEquals(token, manager.getBackupToken())
assertEquals(0L, manager.getLastBackupTime())
}
@Test
fun `test onApkBackedUp() with no prior package metadata`() {
val packageMetadata = PackageMetadata(
time = 0L,
version = Random.nextLong(Long.MAX_VALUE),
installer = getRandomString(),
signatures = listOf("sig")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
}
@Test
fun `test onApkBackedUp() sets system metadata`() {
packageInfo.applicationInfo = ApplicationInfo().apply { flags = FLAG_SYSTEM }
val packageMetadata = PackageMetadata(
time = 0L,
version = Random.nextLong(Long.MAX_VALUE),
installer = getRandomString(),
signatures = listOf("sig")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName))
}
@Test
fun `test onApkBackedUp() with existing package metadata`() {
val packageMetadata = PackageMetadata(
time = time,
version = Random.nextLong(Long.MAX_VALUE),
installer = getRandomString(),
signatures = listOf("sig")
)
initialMetadata.packageMetadataMap[packageName] = packageMetadata
val updatedPackageMetadata = PackageMetadata(
time = time,
version = packageMetadata.version!! + 1,
installer = getRandomString(),
signatures = listOf("sig foo")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, updatedPackageMetadata, storageOutputStream)
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
}
@Test
fun `test onApkBackedUp() limits state changes`() {
var version = Random.nextLong(Long.MAX_VALUE)
var packageMetadata = PackageMetadata(
version = version,
installer = getRandomString(),
signatures = listOf("sig")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
val oldState = UNKNOWN_ERROR
// state doesn't change for APK_AND_DATA
packageMetadata = packageMetadata.copy(version = ++version, state = APK_AND_DATA)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName))
// state doesn't change for QUOTA_EXCEEDED
packageMetadata = packageMetadata.copy(version = ++version, state = QUOTA_EXCEEDED)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName))
// state doesn't change for NO_DATA
packageMetadata = packageMetadata.copy(version = ++version, state = NO_DATA)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName))
// state DOES change for NOT_ALLOWED
packageMetadata = packageMetadata.copy(version = ++version, state = NOT_ALLOWED)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
assertEquals(packageMetadata.copy(state = NOT_ALLOWED), manager.getPackageMetadata(packageName))
}
@Test
fun `test onPackageBackedUp()`() {
packageInfo.applicationInfo.flags = FLAG_SYSTEM
val updatedMetadata = initialMetadata.copy(
time = time,
packageMetadataMap = PackageMetadataMap() // otherwise this isn't copied, but referenced
)
val packageMetadata = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = packageMetadata
expectReadFromCache()
every { clock.time() } returns time
expectModifyMetadata(initialMetadata)
manager.onPackageBackedUp(packageInfo, storageOutputStream)
assertEquals(packageMetadata.copy(state = APK_AND_DATA, system = true), manager.getPackageMetadata(packageName))
assertEquals(time, manager.getLastBackupTime())
}
@Test
fun `test onPackageBackedUp() fails to write to storage`() {
val updateTime = time + 1
val updatedMetadata = initialMetadata.copy(
time = updateTime,
packageMetadataMap = PackageMetadataMap() // otherwise this isn't copied, but referenced
)
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(updateTime, APK_AND_DATA)
expectReadFromCache()
every { clock.time() } returns updateTime
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
try {
manager.onPackageBackedUp(packageInfo, storageOutputStream)
fail()
} catch (e: IOException) {
// expected
}
assertEquals(0L, manager.getLastBackupTime()) // time was reverted
assertEquals(initialMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName))
}
@Test
fun `test onPackageBackedUp() with filled cache`() {
val cachedPackageName = getRandomString()
val cacheTime = time - 1
val cachedMetadata = initialMetadata.copy(time = cacheTime)
cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(cacheTime)
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime)
val updatedMetadata = cachedMetadata.copy(time = time)
updatedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(time, state = APK_AND_DATA)
expectReadFromCache()
every { clock.time() } returns time
expectModifyMetadata(updatedMetadata)
manager.onPackageBackedUp(packageInfo, storageOutputStream)
assertEquals(time, manager.getLastBackupTime())
assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
assertEquals(updatedMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName))
}
@Test
fun `test getBackupToken() on first run`() {
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
assertEquals(0L, manager.getBackupToken())
}
@Test
fun `test getLastBackupTime() on first run`() {
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
assertEquals(0L, manager.getLastBackupTime())
}
@Test
fun `test getLastBackupTime() and getBackupToken() with cached metadata`() {
initialMetadata.time = Random.nextLong()
expectReadFromCache()
assertEquals(initialMetadata.time, manager.getLastBackupTime())
assertEquals(initialMetadata.token, manager.getBackupToken())
}
private fun expectModifyMetadata(metadata: BackupMetadata) {
every { metadataWriter.write(metadata, storageOutputStream) } just Runs
every { metadataWriter.encode(metadata) } returns encodedMetadata
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
every { cacheOutputStream.write(encodedMetadata) } just Runs
}
private fun expectReadFromCache() {
val byteArray = ByteArray(DEFAULT_BUFFER_SIZE)
every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream
every { cacheInputStream.available() } returns byteArray.size andThen 0
every { cacheInputStream.read(byteArray) } returns -1
every { metadataReader.decode(ByteArray(0)) } returns initialMetadata
}
}

View file

@ -3,9 +3,16 @@ package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.mockk
import org.json.JSONArray
import org.json.JSONObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
@ -19,12 +26,7 @@ class MetadataReaderTest {
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = 1.toByte(),
token = Random.nextLong(),
androidVersion = Random.nextInt(),
deviceName = getRandomString()
)
private val metadata = getMetadata()
private val metadataByteArray = encoder.encode(metadata)
@Test
@ -55,10 +57,13 @@ class MetadataReaderTest {
@Test
fun `missing fields throws SecurityException`() {
val json = JSONObject()
json.put(JSON_VERSION, metadata.version.toInt())
json.put(JSON_TOKEN, metadata.token)
json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
val json = JSONObject().apply {
put(JSON_METADATA, JSONObject().apply {
put(JSON_METADATA_VERSION, metadata.version.toInt())
put(JSON_METADATA_TOKEN, metadata.token)
put(JSON_METADATA_SDK_INT, metadata.androidVersion)
})
}
val jsonBytes = json.toString().toByteArray(Utf8)
assertThrows(SecurityException::class.java) {
@ -66,4 +71,103 @@ class MetadataReaderTest {
}
}
@Test
fun `missing meta throws SecurityException`() {
val json = JSONObject().apply {
put("foo", "bat")
}
val jsonBytes = json.toString().toByteArray(Utf8)
assertThrows(SecurityException::class.java) {
decoder.decode(jsonBytes, metadata.version, metadata.token)
}
}
@Test
fun `package metadata gets read`() {
val packageMetadata = HashMap<String, PackageMetadata>().apply {
put("org.example", PackageMetadata(
time = Random.nextLong(),
state = QUOTA_EXCEEDED,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())
))
}
val metadata = getMetadata(packageMetadata)
val metadataByteArray = encoder.encode(metadata)
decoder.decode(metadataByteArray, metadata.version, metadata.token)
}
@Test
fun `package metadata with missing time throws`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_VERSION, Random.nextLong())
put(JSON_PACKAGE_INSTALLER, getRandomString())
put(JSON_PACKAGE_SHA256, getRandomString())
put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString())))
})
val jsonBytes = json.toString().toByteArray(Utf8)
assertThrows(SecurityException::class.java) {
decoder.decode(jsonBytes, metadata.version, metadata.token)
}
}
@Test
fun `package metadata unknown state gets mapped to error`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_TIME, Random.nextLong())
put(JSON_PACKAGE_STATE, getRandomString())
put(JSON_PACKAGE_VERSION, Random.nextLong())
put(JSON_PACKAGE_INSTALLER, getRandomString())
put(JSON_PACKAGE_SHA256, getRandomString())
put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString())))
})
val jsonBytes = json.toString().toByteArray(Utf8)
val metadata = decoder.decode(jsonBytes, metadata.version, metadata.token)
assertEquals(UNKNOWN_ERROR, metadata.packageMetadataMap["org.example"]!!.state)
}
@Test
fun `package metadata missing system gets mapped to false`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_TIME, Random.nextLong())
})
val jsonBytes = json.toString().toByteArray(Utf8)
val metadata = decoder.decode(jsonBytes, metadata.version, metadata.token)
assertFalse(metadata.packageMetadataMap["org.example"]!!.system)
}
@Test
fun `package metadata can only include time`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_TIME, Random.nextLong())
})
val jsonBytes = json.toString().toByteArray(Utf8)
val result = decoder.decode(jsonBytes, metadata.version, metadata.token)
assertEquals(1, result.packageMetadataMap.size)
val packageMetadata = result.packageMetadataMap.getOrElse("org.example") { fail() }
assertNull(packageMetadata.version)
assertNull(packageMetadata.installer)
assertNull(packageMetadata.signatures)
}
private fun getMetadata(packageMetadata: PackageMetadataMap = PackageMetadataMap()): BackupMetadata {
return BackupMetadata(
version = 1.toByte(),
token = Random.nextLong(),
time = Random.nextLong(),
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadataMap = packageMetadata
)
}
}

View file

@ -2,6 +2,10 @@ package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -17,16 +21,82 @@ internal class MetadataWriterDecoderTest {
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = Random.nextBytes(1)[0],
token = Random.nextLong(),
androidVersion = Random.nextInt(),
deviceName = getRandomString()
)
@Test
fun `encoded metadata matches decoded metadata`() {
fun `encoded metadata matches decoded metadata (no packages)`() {
val metadata = getMetadata()
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
@Test
fun `encoded metadata matches decoded metadata (with package, no apk info)`() {
val time = Random.nextLong()
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(time, APK_AND_DATA))
}
val metadata = getMetadata(packages)
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
@Test
fun `encoded metadata matches decoded metadata (full package)`() {
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = APK_AND_DATA,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())))
}
val metadata = getMetadata(packages)
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
@Test
fun `encoded metadata matches decoded metadata (three full packages)`() {
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = QUOTA_EXCEEDED,
system = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString())
))
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = NO_DATA,
system = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())
))
put(getRandomString(), PackageMetadata(
time = 0L,
state = NOT_ALLOWED,
system = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())
))
}
val metadata = getMetadata(packages)
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
private fun getMetadata(packageMetadata: HashMap<String, PackageMetadata> = HashMap()): BackupMetadata {
return BackupMetadata(
version = Random.nextBytes(1)[0],
token = Random.nextLong(),
time = Random.nextLong(),
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadataMap = packageMetadata
)
}
}

View file

@ -16,11 +16,33 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.header.HeaderWriterImpl
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.MetadataWriterImpl
import com.stevesoltys.seedvault.transport.backup.*
import com.stevesoltys.seedvault.transport.restore.*
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.transport.backup.ApkBackup
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
import com.stevesoltys.seedvault.transport.backup.FullBackup
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.InputFactory
import com.stevesoltys.seedvault.transport.backup.KVBackup
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.restore.FullRestore
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestore
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.OutputFactory
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@ -35,7 +57,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(cryptoImpl)
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val backupPlugin = mockk<BackupPlugin>()
@ -43,21 +64,25 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val apkBackup = mockk<ApkBackup>()
private val packageService:PackageService = mockk()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, clock, packageService, metadataManager, settingsManager, 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(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val restore = RestoreCoordinator(context, settingsManager, metadataManager, notificationManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
private val token = Random.nextLong()
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val metadataOutputStream = ByteArrayOutputStream()
private val packageMetadata = PackageMetadata(time = 0L)
private val key = "RestoreKey"
private val key64 = key.encodeBase64()
private val key2 = "RestoreKey2"
@ -92,7 +117,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { settingsManager.saveNewBackupTime() } just Runs
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
@ -143,7 +171,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { settingsManager.saveNewBackupTime() } just Runs
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
@ -179,7 +209,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { settingsManager.saveNewBackupTime() } just Runs
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// perform backup to output stream
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))

View file

@ -1,24 +1,41 @@
package com.stevesoltys.seedvault.transport
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_INSTALLED
import android.content.pm.PackageInfo
import android.content.pm.SigningInfo
import android.util.Log
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
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
import kotlin.random.Random
@TestInstance(PER_METHOD)
abstract class TransportTest {
protected val clock: Clock = mockk()
protected val crypto = mockk<Crypto>()
protected val settingsManager = mockk<SettingsManager>()
protected val metadataManager = mockk<MetadataManager>()
protected val context = mockk<Context>(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }
protected val sigInfo: SigningInfo = mockk()
protected val packageInfo = PackageInfo().apply {
packageName = "org.example"
longVersionCode = Random.nextLong()
applicationInfo = ApplicationInfo().apply {
flags = FLAG_ALLOW_BACKUP or FLAG_INSTALLED
}
signingInfo = sigInfo
}
init {
mockkStatic(Log::class)

View file

@ -0,0 +1,143 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.util.PackageUtils
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.nio.file.Path
import kotlin.random.Random
internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk()
private val streamGetter: () -> OutputStream = mockk()
private val apkBackup = ApkBackup(pm, settingsManager, metadataManager)
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
private val sigs = arrayOf(Signature(signatureBytes))
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
version = packageInfo.longVersionCode - 1,
signatures = listOf("AwIB")
)
init {
mockkStatic(PackageUtils::class)
}
@Test
fun `does not back up @pm@`() {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does not back up when setting disabled`() {
every { settingsManager.backupApks() } returns false
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does not back up system apps`() {
packageInfo.applicationInfo.flags = FLAG_SYSTEM
every { settingsManager.backupApks() } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does not back up the same version`() {
packageInfo.applicationInfo.flags = FLAG_UPDATED_SYSTEM_APP
val packageMetadata = packageMetadata.copy(
version = packageInfo.longVersionCode
)
expectChecks(packageMetadata)
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does back up the same version when signatures changes`() {
packageInfo.applicationInfo.sourceDir = "/tmp/doesNotExist"
expectChecks()
assertThrows(IOException::class.java) {
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
}
@Test
fun `do not accept empty signature`() {
every { settingsManager.backupApks() } returns true
every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata
every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns emptyArray()
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `test successful APK backup`(@TempDir tmpDir: Path) {
val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
val tmpFile = File(tmpDir.toAbsolutePath().toString())
packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply {
assertTrue(createNewFile())
writeBytes(apkBytes)
}.absolutePath
val apkOutputStream = ByteArrayOutputStream()
val updatedMetadata = PackageMetadata(
time = 0L,
state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = packageMetadata.signatures
)
expectChecks()
every { streamGetter.invoke() } returns apkOutputStream
every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer
every { metadataManager.onApkBackedUp(packageInfo, updatedMetadata, outputStream) } just Runs
assertEquals(updatedMetadata, apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
}
private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) {
every { settingsManager.backupApks() } returns true
every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns sigs
}
}

View file

@ -2,40 +2,68 @@ package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.content.pm.PackageInfo
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.MetadataWriter
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.settings.Storage
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.OutputStream
import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() {
internal class BackupCoordinatorTest : BackupTest() {
private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>()
private val metadataWriter = mockk<MetadataWriter>()
private val apkBackup = mockk<ApkBackup>()
private val packageService: PackageService = mockk()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, packageService, metadataManager, settingsManager, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
every { settingsManager.getBackupToken() } returns token
expectWritingMetadata(token)
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns true // TODO test when false
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } 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 does no-op when already initialized`() {
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns false
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -45,9 +73,8 @@ internal class BackupCoordinatorTest: BackupTest() {
@Test
fun `error notification when device initialization fails`() {
val storage = Storage(Uri.EMPTY, getRandomString(), false)
every { plugin.initializeDevice() } throws IOException()
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs
@ -66,7 +93,8 @@ internal class BackupCoordinatorTest: BackupTest() {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { plugin.initializeDevice() } throws IOException()
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
@ -87,6 +115,7 @@ internal class BackupCoordinatorTest: BackupTest() {
val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong()
expectApkBackupAndMetadataWrite()
if (isFullBackup) {
every { full.getQuota() } returns quota
} else {
@ -95,6 +124,20 @@ internal class BackupCoordinatorTest: BackupTest() {
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
}
@Test
fun `isAppEligibleForBackup() exempts plugin provider and blacklisted apps`() {
every {
settingsManager.isBackupEnabled(packageInfo.packageName)
} returns true andThen false andThen true
every {
plugin.providerPackageName
} returns packageInfo.packageName andThen "new.package" andThen "new.package"
assertFalse(backup.isAppEligibleForBackup(packageInfo, true))
assertFalse(backup.isAppEligibleForBackup(packageInfo, true))
assertTrue(backup.isAppEligibleForBackup(packageInfo, true))
}
@Test
fun `clearing KV backup data throws`() {
every { kv.clearBackupData(packageInfo) } throws IOException()
@ -129,6 +172,9 @@ internal class BackupCoordinatorTest: BackupTest() {
every { kv.hasState() } returns true
every { full.hasState() } returns false
every { kv.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { kv.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
@ -140,14 +186,104 @@ internal class BackupCoordinatorTest: BackupTest() {
every { kv.hasState() } returns false
every { full.hasState() } returns true
every { full.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { full.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
}
private fun expectWritingMetadata(token: Long = this.token) {
@Test
fun `metadata does not get updated when no APK was backed up`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
}
@Test
fun `app exceeding quota gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1) } returns TRANSPORT_QUOTA_EXCEEDED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo, QUOTA_EXCEEDED, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(TRANSPORT_QUOTA_EXCEEDED,
backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1))
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo, QUOTA_EXCEEDED, metadataOutputStream)
}
}
@Test
fun `app with no data gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo, NO_DATA, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo, NO_DATA, metadataOutputStream)
}
}
@Test
fun `not allowed apps get their APKs backed up during @pm@ backup`() {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
val notAllowedPackages = listOf(
PackageInfo().apply { packageName = "org.example.1" },
PackageInfo().apply { packageName = "org.example.2" }
)
val packageMetadata: PackageMetadata = mockk()
every { settingsManager.getStorage() } returns storage // to check for removable storage
every { packageService.notAllowedPackages } returns notAllowedPackages
// no backup needed
every { apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) } returns null
// was backed up, get new packageMetadata
every { apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataWriter.write(metadataOutputStream, token) } just Runs
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata, metadataOutputStream) } just Runs
// do actual @pm@ backup
every { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
assertEquals(TRANSPORT_OK,
backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
verify {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any())
apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any())
}
}
private fun expectApkBackupAndMetadataWrite() {
every { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs
}
}

View file

@ -1,11 +1,16 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
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.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.FileInputStream
import java.io.IOException
@ -56,27 +61,9 @@ internal class FullBackupTest : BackupTest() {
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()
every { inputFactory.getInputStream(data) } returns inputStream
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
@ -87,7 +74,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData first call over quota`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = (quota + 1).toInt()
expectSendData(numBytes)
expectClearState()
@ -102,7 +90,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData second call over quota`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = quota.toInt()
expectSendData(numBytes1)
val numBytes2 = 1
@ -121,7 +110,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData throws exception when reading from InputStream`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } throws IOException()
expectClearState()
@ -134,9 +124,44 @@ internal class FullBackupTest : BackupTest() {
assertFalse(backup.hasState())
}
@Test
fun `sendBackupData throws exception when getting outputStream`() {
every { inputFactory.getInputStream(data) } returns inputStream
every { plugin.getQuota() } returns quota
every { plugin.getOutputStream(packageInfo) } 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 header`() {
every { inputFactory.getInputStream(data) } returns inputStream
every { plugin.getQuota() } returns quota
every { plugin.getOutputStream(packageInfo) } returns outputStream
every { inputFactory.getInputStream(data) } returns inputStream
every { headerWriter.writeVersion(outputStream, header) } 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 { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { plugin.getQuota() } returns quota
every { inputStream.read(any(), any(), bytes.size) } returns bytes.size
every { crypto.encryptSegment(outputStream, any()) } throws IOException()
@ -152,7 +177,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `sendBackupData runs ok`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = (quota / 2).toInt()
expectSendData(numBytes1)
val numBytes2 = (quota / 2).toInt()
@ -178,7 +204,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `cancel full backup runs ok`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } just Runs
@ -190,7 +217,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `cancel full backup ignores exception when calling plugin`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
@ -202,19 +230,24 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `clearState throws exception when flushing OutputStream`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = 42
expectSendData(numBytes)
every { outputStream.write(closeBytes) } just Runs
every { outputStream.flush() } throws IOException()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes))
assertEquals(TRANSPORT_ERROR, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `clearState ignores exception when closing OutputStream`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
every { outputStream.close() } throws IOException()
every { inputStream.close() } just Runs
@ -228,7 +261,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `clearState ignores exception when closing InputStream`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
every { inputStream.close() } throws IOException()
@ -242,7 +276,8 @@ internal class FullBackupTest : BackupTest() {
@Test
fun `clearState ignores exception when closing ParcelFileDescriptor`() {
expectPerformFullBackup()
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
every { inputStream.close() } just Runs
@ -254,9 +289,8 @@ internal class FullBackupTest : BackupTest() {
assertFalse(backup.hasState())
}
private fun expectPerformFullBackup() {
private fun expectInitializeOutputStream() {
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
}

View file

@ -1,7 +1,11 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupDataInput
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.FLAG_INCREMENTAL
import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
@ -10,7 +14,9 @@ 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.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.util.*

View file

@ -0,0 +1,245 @@
package com.stevesoltys.seedvault.transport.restore
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_INSTALLED
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.io.File
import java.nio.file.Path
import kotlin.random.Random
@ExperimentalCoroutinesApi
internal class ApkRestoreTest : RestoreTest() {
private val pm: PackageManager = mockk()
private val strictContext: Context = mockk<Context>().apply {
every { packageManager } returns pm
}
private val restorePlugin: RestorePlugin = mockk()
private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = ApkRestore(strictContext, restorePlugin, apkInstaller)
private val icon: Drawable = mockk()
private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
version = packageInfo.longVersionCode - 1,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = listOf("AwIB")
)
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
private val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
private val apkInputStream = ByteArrayInputStream(apkBytes)
private val appName = getRandomString()
private val installerName = packageMetadata.installer
init {
// as we don't do strict signature checking, we can use a relaxed mock
packageInfo.signingInfo = mockk(relaxed = true)
}
@Test
fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change SHA256 signature to random
val packageMetadata = packageMetadata.copy(sha256 = getRandomString())
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change package name to random string
packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(IN_PROGRESS, result.status)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
val installResult = MutableInstallResult(1).apply {
put(packageName, ApkRestoreResult(packageName, progress = 1, total = 1, status = SUCCEEDED))
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf(installResult)
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(IN_PROGRESS, result.status)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
assertEquals(SUCCEEDED, result.status)
}
else -> fail()
}
i++
}
}
@Test
fun `test system apps only get reinstalled when older system apps exist`(@TempDir tmpDir: Path) = runBlocking {
val packageMetadata = this@ApkRestoreTest.packageMetadata.copy(system = true)
packageMetadataMap[packageName] = packageMetadata
packageInfo.applicationInfo = mockk()
val installedPackageInfo: PackageInfo = mockk()
val willFail = Random.nextBoolean()
installedPackageInfo.applicationInfo = ApplicationInfo().apply {
// will not fail when app really is a system app
flags = if (willFail) FLAG_INSTALLED else FLAG_SYSTEM or FLAG_UPDATED_SYSTEM_APP
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { packageInfo.applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
if (!willFail) {
val installResult = MutableInstallResult(1).apply {
put(packageName, ApkRestoreResult(packageName, progress = 1, total = 1, status = SUCCEEDED))
}
every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf(installResult)
}
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(IN_PROGRESS, result.status)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
if (willFail) {
assertEquals(FAILED, result.status)
} else {
assertEquals(SUCCEEDED, result.status)
}
}
else -> fail()
}
i++
}
}
}

View file

@ -1,6 +1,9 @@
package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupTransport.*
import android.app.backup.BackupTransport.NO_MORE_DATA
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
@ -9,7 +12,11 @@ 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.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.EOFException

View file

@ -8,7 +8,11 @@ import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.*
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verifyAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test

View file

@ -1,20 +1,32 @@
package com.stevesoltys.seedvault.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.RestoreDescription.NO_MORE_PACKAGES
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.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 io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InputStream
@ -22,18 +34,27 @@ import kotlin.random.Random
internal class RestoreCoordinatorTest : TransportTest() {
private val notificationManager: BackupNotificationManager = mockk()
private val plugin = mockk<RestorePlugin>()
private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(settingsManager, plugin, kv, full, metadataReader)
private val restore = RestoreCoordinator(context, settingsManager, metadataManager, notificationManager, plugin, kv, full, metadataReader)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
private val storage: Storage = mockk()
private val documentFile: DocumentFile = mockk()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
private val pmPackageInfoArray = arrayOf(
PackageInfo().apply { packageName = "@pm@" },
packageInfo
)
private val packageName = packageInfo.packageName
private val storageName = getRandomString()
@Test
fun `getAvailableRestoreSets() builds set from plugin response`() {
@ -41,6 +62,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
val metadata = BackupMetadata(
token = token,
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString())
every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
@ -56,7 +78,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
every { settingsManager.getBackupToken() } returns token
every { metadataManager.getBackupToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet())
}
@ -73,6 +95,46 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
}
@Test
fun `startRestore() optimized auto-restore with removed storage shows notification`() {
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
every { storage.name } returns storageName
every { notificationManager.onRemovableStorageNotAvailableForRestore(packageName, storageName) } just Runs
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
verify(exactly = 1) { notificationManager.onRemovableStorageNotAvailableForRestore(packageName, storageName) }
}
@Test
fun `startRestore() optimized auto-restore with available storage shows no notification`() {
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns true
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
verify(exactly = 0) { notificationManager.onRemovableStorageNotAvailableForRestore(packageName, storageName) }
}
@Test
fun `startRestore() optimized auto-restore with removed storage but no backup shows no notification`() {
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
every { metadataManager.getPackageMetadata(packageName) } returns null
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
verify(exactly = 0) { notificationManager.onRemovableStorageNotAvailableForRestore(packageName, storageName) }
}
@Test
fun `nextRestorePackage() throws without startRestore()`() {
assertThrows(IllegalStateException::class.javaObjectType) {

View file

@ -2,9 +2,9 @@ package com.stevesoltys.seedvault.transport.restore
import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.transport.TransportTest
import io.mockk.mockk
import java.io.InputStream
import kotlin.random.Random

View file

@ -3,6 +3,7 @@
<privapp-permissions package="com.stevesoltys.seedvault">
<permission name="android.permission.BACKUP"/>
<permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.INSTALL_PACKAGES"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
</privapp-permissions>
</permissions>