Show heads-up notification when auto-restore fails due to removed storage

This commit is contained in:
Torsten Grote 2020-01-03 12:51:44 -03:00
parent 783e676be2
commit 2bcf82d607
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
10 changed files with 186 additions and 12 deletions

View file

@ -29,6 +29,10 @@
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"
@ -87,5 +91,13 @@
android:resource="@xml/device_filter" />
</receiver>
<receiver
android:name=".restore.RestoreErrorBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.stevesoltys.seedvault.action.UNINSTALL" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -2,25 +2,32 @@ package com.stevesoltys.seedvault
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.NotificationManager.*
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import androidx.core.app.NotificationCompat.*
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.SettingsActivity
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 {
@ -35,6 +42,11 @@ class BackupNotificationManager(private val context: Context) {
return NotificationChannel(CHANNEL_ID_ERROR, title, IMPORTANCE_DEFAULT)
}
private fun getRestoreErrorChannel(): NotificationChannel {
val title = context.getString(R.string.notification_restore_error_channel_title)
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
}
private val observerBuilder = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload)
}
@ -43,6 +55,10 @@ class BackupNotificationManager(private val context: Context) {
setSmallIcon(R.drawable.ic_cloud_error)
}
private val restoreErrorBuilder = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {
setSmallIcon(R.drawable.ic_cloud_error)
}
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) {
val notification = observerBuilder.apply {
setContentTitle(context.getString(R.string.notification_title))
@ -93,4 +109,33 @@ 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 = restoreErrorBuilder.apply {
setContentTitle(context.getString(R.string.notification_restore_error_title, appName))
setContentText(context.getString(R.string.notification_restore_error_text, storageName))
setWhen(System.currentTimeMillis())
setAutoCancel(true)
priority = PRIORITY_HIGH
mActions = arrayListOf(action)
}.build()
nm.notify(NOTIFICATION_ID_RESTORE_ERROR, notification)
}
fun onRestoreErrorSeen() {
nm.cancel(NOTIFICATION_ID_RESTORE_ERROR)
}
}

View file

@ -23,6 +23,7 @@ private val TAG = UsbIntentReceiver::class.java.simpleName
class UsbIntentReceiver : UsbMonitor() {
// 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>() }

View file

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

View file

@ -180,9 +180,10 @@ 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)
getString(R.string.settings_auto_restore_summary_usb, storage.name)
} else {
autoRestore.setSummary(R.string.settings_auto_restore_summary)
}

View file

@ -6,16 +6,20 @@ import android.app.backup.IBackupManager
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
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
import java.io.IOException
@ -32,7 +36,10 @@ private class RestoreCoordinatorState(
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,
@ -113,7 +120,19 @@ internal class RestoreCoordinator(
// 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) {
Log.d(TAG, "Optimize for single package restore of ${packages[1].packageName}")
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
@ -245,4 +264,10 @@ internal class RestoreCoordinator(
fun isFailedPackage(packageName: String) = packageName in failedPackages
// TODO this is plugin specific, needs to be factored out when supporting different plugins
private fun isStorageRemovableAndNotAvailable(): Boolean {
val storage = settingsManager.getStorage() ?: return false
return storage.isUsb && !storage.getDocumentFile(context).isDirectory
}
}

View file

@ -8,5 +8,5 @@ val restoreModule = module {
factory { ApkRestore(androidContext(), get()) }
single { KVRestore(get<RestorePlugin>().kvRestorePlugin, get(), get(), get()) }
single { FullRestore(get<RestorePlugin>().fullRestorePlugin, get(), get(), get()) }
single { RestoreCoordinator(get(), get(), get(), get(), get()) }
single { RestoreCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -18,7 +18,7 @@
<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_usb">Note: Your USB flash drive needs to be plugged in for this to work.</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_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>
@ -80,6 +80,11 @@
<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>

View file

@ -53,7 +53,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
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(metadataManager, 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)

View file

@ -1,19 +1,21 @@
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.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 io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
@ -22,18 +24,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(metadataManager, 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`() {
@ -74,6 +85,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) {