Show heads-up notification when auto-restore fails due to removed storage
This commit is contained in:
parent
783e676be2
commit
2bcf82d607
10 changed files with 186 additions and 12 deletions
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>() }
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Reference in a new issue