Merge pull request #75 from stevesoltys/develop
Merge develop into master
This commit is contained in:
commit
a585324c71
100 changed files with 4417 additions and 632 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
13
app/src/main/java/com/stevesoltys/seedvault/Clock.kt
Normal file
13
app/src/main/java/com/stevesoltys/seedvault/Clock.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,57 +35,92 @@ 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 val rootBackupDir: DocumentFile? by lazy {
|
||||
val parent = storage?.getDocumentFile(context) ?: return@lazy null
|
||||
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)
|
||||
rootDir
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating root backup dir.", e)
|
||||
null
|
||||
internal var storage: Storage? = null
|
||||
get() {
|
||||
if (field == null) field = settingsManager.getStorage()
|
||||
return field
|
||||
}
|
||||
|
||||
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)
|
||||
rootDir
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating root backup dir.", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private val currentToken: Long by lazy {
|
||||
if (token != 0L) token
|
||||
else settingsManager.getAndSaveNewBackupToken().apply {
|
||||
Log.d(TAG, "Using a fresh backup token: $this")
|
||||
}
|
||||
}
|
||||
|
||||
private val currentSetDir: DocumentFile? by lazy {
|
||||
val currentSetName = currentToken.toString()
|
||||
try {
|
||||
rootBackupDir?.createOrGetDirectory(currentSetName)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating current restore set dir.", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val currentFullBackupDir: DocumentFile? by lazy {
|
||||
try {
|
||||
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating full backup dir.", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val currentKvBackupDir: DocumentFile? by lazy {
|
||||
try {
|
||||
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating K/V backup dir.", e)
|
||||
null
|
||||
}
|
||||
fun reset(newToken: Long) {
|
||||
storage = null
|
||||
currentToken = newToken
|
||||
rootBackupDir = null
|
||||
currentSetDir = null
|
||||
currentKvBackupDir = null
|
||||
currentFullBackupDir = null
|
||||
}
|
||||
|
||||
fun getAuthority(): String? = storage?.uri?.authority
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
|
@ -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))
|
||||
} else {
|
||||
getString(R.string.restore_finished_warning_only_installed, null)
|
||||
}
|
||||
warningView.visibility = VISIBLE
|
||||
viewModel.restoreBackupResult.observe(this, Observer { finished ->
|
||||
button.isEnabled = true
|
||||
if (finished.hasError()) {
|
||||
backupNameView.text = finished.errorMsg
|
||||
backupNameView.setTextColor(getColor(requireContext(), R.color.red))
|
||||
} 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 val installResult: LiveData<InstallResult> = switchMap(mChosenRestorableBackup) { backup ->
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
getInstallResult(backup)
|
||||
}
|
||||
|
||||
internal fun loadRestoreSets() {
|
||||
val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
|
||||
this.session = session
|
||||
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false }
|
||||
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
|
||||
|
||||
if (session == null) {
|
||||
Log.e(TAG, "beginRestoreSession() returned null session")
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
return
|
||||
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
|
||||
value = LinkedList<AppRestoreResult>().apply {
|
||||
add(AppRestoreResult(MAGIC_PACKAGE_MANAGER, getAppName(app, MAGIC_PACKAGE_MANAGER), IN_PROGRESS))
|
||||
}
|
||||
val observer = this.observer ?: RestoreObserver()
|
||||
this.observer = observer
|
||||
}
|
||||
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 }
|
||||
|
|
|
@ -23,6 +23,7 @@ class AboutDialogFragment : DialogFragment() {
|
|||
val linkMovementMethod = LinkMovementMethod.getInstance()
|
||||
licenseView.movementMethod = linkMovementMethod
|
||||
authorView.movementMethod = linkMovementMethod
|
||||
designView.movementMethod = linkMovementMethod
|
||||
sponsorView.movementMethod = linkMovementMethod
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
backupLocation.summary = 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 = 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() {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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 {
|
||||
// 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) {
|
||||
return TRANSPORT_PACKAGE_REJECTED
|
||||
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 (getBackupBackoff() != 0L) {
|
||||
return TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
// hook in here to back up APKs of apps that are otherwise not allowed for backup
|
||||
backUpNotAllowedPackages()
|
||||
}
|
||||
|
||||
val result = kv.performBackup(packageInfo, data, flags)
|
||||
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
|
||||
return result
|
||||
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) {
|
||||
val outputStream = plugin.getMetadataOutputStream()
|
||||
metadataWriter.write(outputStream, token)
|
||||
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()
|
||||
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 {
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}.")
|
||||
|
||||
// get OutputStream to write backup data into
|
||||
val outputStream = try {
|
||||
plugin.getOutputStream(targetPackage)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e)
|
||||
return backupError(TRANSPORT_ERROR)
|
||||
}
|
||||
|
||||
// create new state
|
||||
val inputStream = inputFactory.getInputStream(socket)
|
||||
state = FullBackupState(targetPackage, socket, inputStream, outputStream)
|
||||
|
||||
// store version header
|
||||
val state = this.state ?: throw AssertionError()
|
||||
val header = VersionHeader(packageName = state.packageName)
|
||||
try {
|
||||
headerWriter.writeVersion(state.outputStream, header)
|
||||
crypto.encryptHeader(state.outputStream, header)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error writing backup header", e)
|
||||
return backupError(TRANSPORT_ERROR)
|
||||
}
|
||||
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)
|
||||
throw(e)
|
||||
}
|
||||
// store version header
|
||||
val state = this.state ?: throw AssertionError()
|
||||
val header = VersionHeader(packageName = state.packageName)
|
||||
try {
|
||||
headerWriter.writeVersion(outputStream, header)
|
||||
crypto.encryptHeader(outputStream, header)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error writing backup header", e)
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
|
15
app/src/main/java/com/stevesoltys/seedvault/ui/UiUtils.kt
Normal file
15
app/src/main/java/com/stevesoltys/seedvault/ui/UiUtils.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.*
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
10
app/src/main/res/drawable/ic_apps.xml
Normal file
10
app/src/main/res/drawable/ic_apps.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.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>
|
9
app/src/main/res/drawable/ic_check_green.xml
Normal file
9
app/src/main/res/drawable/ic_check_green.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_cloud_done.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_done.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.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>
|
9
app/src/main/res/drawable/ic_error_red.xml
Normal file
9
app/src/main/res/drawable/ic_error_red.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_launcher_default.xml
Normal file
5
app/src/main/res/drawable/ic_launcher_default.xml
Normal 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>
|
170
app/src/main/res/drawable/ic_launcher_default_background.xml
Normal file
170
app/src/main/res/drawable/ic_launcher_default_background.xml
Normal 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>
|
34
app/src/main/res/drawable/ic_launcher_default_foreground.xml
Normal file
34
app/src/main/res/drawable/ic_launcher_default_foreground.xml
Normal 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>
|
9
app/src/main/res/drawable/ic_warning_yellow.xml
Normal file
9
app/src/main/res/drawable/ic_warning_yellow.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
27
app/src/main/res/layout/fragment_app_status.xml
Normal file
27
app/src/main/res/layout/fragment_app_status.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
79
app/src/main/res/layout/list_item_app_status.xml
Normal file
79
app/src/main/res/layout/list_item_app_status.xml
Normal 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>
|
10
app/src/main/res/menu/app_status_menu.xml
Normal file
10
app/src/main/res/menu/app_status_menu.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.*
|
||||
|
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue