Show error notification when backup fails
The implementation is rudimentary for now. E.g. The notification is only shown when a device init fails which seems to be triggered after the first failure.
This commit is contained in:
parent
683268a15f
commit
c714a4e7e1
12 changed files with 156 additions and 54 deletions
|
@ -5,6 +5,7 @@ import android.app.Application
|
|||
import android.app.backup.IBackupManager
|
||||
import android.content.Context.BACKUP_SERVICE
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ServiceManager.getService
|
||||
import android.util.Log
|
||||
|
@ -14,6 +15,8 @@ import com.stevesoltys.backup.settings.getDeviceName
|
|||
import com.stevesoltys.backup.settings.setDeviceName
|
||||
import io.github.novacrypto.hashing.Sha256.sha256Twice
|
||||
|
||||
private const val URI_AUTHORITY_EXTERNAL_STORAGE = "com.android.externalstorage.documents"
|
||||
|
||||
private val TAG = Backup::class.java.simpleName
|
||||
|
||||
/**
|
||||
|
@ -31,6 +34,10 @@ class Backup : Application() {
|
|||
}
|
||||
}
|
||||
|
||||
val notificationManager by lazy {
|
||||
BackupNotificationManager(this)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
storeDeviceName()
|
||||
|
@ -53,3 +60,5 @@ class Backup : Application() {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
package com.stevesoltys.backup
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.NotificationManager.IMPORTANCE_DEFAULT
|
||||
import android.app.NotificationManager.IMPORTANCE_LOW
|
||||
import android.app.PendingIntent
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat.*
|
||||
import com.stevesoltys.backup.settings.SettingsActivity
|
||||
|
||||
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
|
||||
private const val CHANNEL_ID_ERROR = "NotificationError"
|
||||
private const val NOTIFICATION_ID_OBSERVER = 1
|
||||
private const val NOTIFICATION_ID_ERROR = 2
|
||||
|
||||
class BackupNotificationManager(private val context: Context) {
|
||||
|
||||
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
|
||||
createNotificationChannel(getObserverChannel())
|
||||
createNotificationChannel(getErrorChannel())
|
||||
}
|
||||
|
||||
private fun getObserverChannel(): NotificationChannel {
|
||||
val title = context.getString(R.string.notification_channel_title)
|
||||
return NotificationChannel(CHANNEL_ID_OBSERVER, title, IMPORTANCE_LOW).apply {
|
||||
enableVibration(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorChannel(): NotificationChannel {
|
||||
val title = context.getString(R.string.notification_error_channel_title)
|
||||
return NotificationChannel(CHANNEL_ID_ERROR, title, IMPORTANCE_DEFAULT)
|
||||
}
|
||||
|
||||
private val observerBuilder = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||
}
|
||||
|
||||
private val errorBuilder = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_error)
|
||||
}
|
||||
|
||||
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) {
|
||||
val notification = observerBuilder.apply {
|
||||
setContentTitle(context.getString(R.string.notification_title))
|
||||
setContentText(app)
|
||||
setProgress(expected, transferred, false)
|
||||
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
||||
}
|
||||
|
||||
fun onBackupResult(app: CharSequence, status: Int, userInitiated: Boolean) {
|
||||
val title = context.getString(when (status) {
|
||||
0 -> R.string.notification_backup_result_complete
|
||||
TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected
|
||||
else -> R.string.notification_backup_result_error
|
||||
})
|
||||
val notification = observerBuilder.apply {
|
||||
setContentTitle(title)
|
||||
setContentText(app)
|
||||
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
||||
}
|
||||
|
||||
fun onBackupFinished() {
|
||||
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
||||
}
|
||||
|
||||
fun onBackupError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
|
||||
val actionText = context.getString(R.string.notification_error_action)
|
||||
val action = Action(R.drawable.ic_storage, actionText, pendingIntent)
|
||||
val notification = errorBuilder.apply {
|
||||
setContentTitle(context.getString(R.string.notification_error_title))
|
||||
setContentText(context.getString(R.string.notification_error_text))
|
||||
addAction(action)
|
||||
setOnlyAlertOnce(true)
|
||||
setAutoCancel(true)
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID_ERROR, notification)
|
||||
}
|
||||
|
||||
fun onBackupErrorSeen() {
|
||||
nm.cancel(NOTIFICATION_ID_ERROR)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,40 +1,18 @@
|
|||
package com.stevesoltys.backup
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.NotificationManager.IMPORTANCE_LOW
|
||||
import android.app.backup.BackupProgress
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.app.backup.IBackupObserver
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.Log.INFO
|
||||
import android.util.Log.isLoggable
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
|
||||
import androidx.core.app.NotificationCompat.PRIORITY_LOW
|
||||
|
||||
private const val CHANNEL_ID = "NotificationBackupObserver"
|
||||
private const val NOTIFICATION_ID = 1042
|
||||
|
||||
private val TAG = NotificationBackupObserver::class.java.simpleName
|
||||
|
||||
class NotificationBackupObserver(
|
||||
private val context: Context,
|
||||
private val userInitiated: Boolean) : IBackupObserver.Stub() {
|
||||
class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub() {
|
||||
|
||||
private val pm = context.packageManager
|
||||
private val nm = context.getSystemService(NotificationManager::class.java).apply {
|
||||
val title = context.getString(R.string.notification_channel_title)
|
||||
val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_LOW).apply {
|
||||
enableVibration(false)
|
||||
}
|
||||
createNotificationChannel(channel)
|
||||
}
|
||||
private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
|
||||
}
|
||||
private val nm = (context.applicationContext as Backup).notificationManager
|
||||
|
||||
/**
|
||||
* This method could be called several times for packages with full data backup.
|
||||
|
@ -44,17 +22,13 @@ class NotificationBackupObserver(
|
|||
* @param backupProgress Current progress of backup for the package.
|
||||
*/
|
||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||
val transferred = backupProgress.bytesTransferred
|
||||
val expected = backupProgress.bytesExpected
|
||||
val transferred = backupProgress.bytesTransferred.toInt()
|
||||
val expected = backupProgress.bytesExpected.toInt()
|
||||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
|
||||
}
|
||||
val notification = notificationBuilder.apply {
|
||||
setContentTitle(context.getString(R.string.notification_title))
|
||||
setContentText(getAppName(currentBackupPackage))
|
||||
setProgress(expected.toInt(), transferred.toInt(), false)
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
val app = getAppName(currentBackupPackage)
|
||||
nm.onBackupUpdate(app, transferred, expected, userInitiated)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -71,16 +45,7 @@ class NotificationBackupObserver(
|
|||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Completed. Target: $target, status: $status")
|
||||
}
|
||||
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 = notificationBuilder.apply {
|
||||
setContentTitle(title)
|
||||
setContentText(getAppName(target))
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
nm.onBackupResult(getAppName(target), status, userInitiated)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,7 +59,7 @@ class NotificationBackupObserver(
|
|||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Backup finished. Status: $status")
|
||||
}
|
||||
nm.cancel(NOTIFICATION_ID)
|
||||
nm.onBackupFinished()
|
||||
}
|
||||
|
||||
private fun getAppName(packageId: String): CharSequence {
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.view.MenuItem
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.LiveEventHandler
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
|
@ -25,8 +26,8 @@ class SettingsActivity : AppCompatActivity() {
|
|||
setContentView(R.layout.activity_settings)
|
||||
|
||||
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
||||
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore ->
|
||||
if (wasEmptyBefore) showFragment(SettingsFragment())
|
||||
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
|
||||
if (initialSetUp) showFragment(SettingsFragment())
|
||||
else supportFragmentManager.popBackStack()
|
||||
})
|
||||
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
|
||||
|
@ -54,8 +55,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||
// check that backup is provisioned
|
||||
if (!viewModel.recoveryCodeIsSet()) {
|
||||
showRecoveryCodeActivity()
|
||||
} else if (!viewModel.locationIsSet()) {
|
||||
} else if (!viewModel.validLocationIsSet()) {
|
||||
showFragment(BackupLocationFragment())
|
||||
// remove potential error notifications
|
||||
(application as Backup).notificationManager.onBackupErrorSeen()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,10 +5,12 @@ import android.content.Intent
|
|||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.LiveEvent
|
||||
import com.stevesoltys.backup.MutableLiveEvent
|
||||
import com.stevesoltys.backup.isOnExternalStorage
|
||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.backup.transport.requestBackup
|
||||
|
||||
|
@ -30,7 +32,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||
|
||||
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
|
||||
fun locationIsSet() = getBackupFolderUri(getApplication()) != null
|
||||
|
||||
fun validLocationIsSet(): Boolean {
|
||||
val uri = getBackupFolderUri(app) ?: return false
|
||||
if (uri.isOnExternalStorage()) return true // might be a temporary failure
|
||||
val file = DocumentFile.fromTreeUri(app, uri) ?: return false
|
||||
return file.isDirectory
|
||||
}
|
||||
|
||||
fun handleChooseFolderResult(result: Intent?) {
|
||||
val folderUri = result?.data ?: return
|
||||
|
@ -40,13 +48,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
|
||||
|
||||
// check if this is initial set-up or a later change
|
||||
val wasEmptyBefore = getBackupFolderUri(app) == null
|
||||
val initialSetUp = !validLocationIsSet()
|
||||
|
||||
// store backup folder location in settings
|
||||
setBackupFolderUri(app, folderUri)
|
||||
|
||||
// notify the UI that the location has been set
|
||||
locationWasSet.setEvent(wasEmptyBefore)
|
||||
locationWasSet.setEvent(initialSetUp)
|
||||
|
||||
// stop backup service to be sure the old location will get updated
|
||||
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
||||
|
|
|
@ -36,8 +36,9 @@ class PluginManager(context: Context) {
|
|||
private val inputFactory = InputFactory()
|
||||
private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto)
|
||||
private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
|
||||
private val notificationManager = (context.applicationContext as Backup).notificationManager
|
||||
|
||||
internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup)
|
||||
internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
|
||||
|
||||
|
||||
private val restorePlugin = DocumentsProviderRestorePlugin(storage)
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.backup.BackupNotificationManager
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = BackupCoordinator::class.java.simpleName
|
||||
|
@ -16,7 +17,8 @@ private val TAG = BackupCoordinator::class.java.simpleName
|
|||
class BackupCoordinator(
|
||||
private val plugin: BackupPlugin,
|
||||
private val kv: KVBackup,
|
||||
private val full: FullBackup) {
|
||||
private val full: FullBackup,
|
||||
private val nm: BackupNotificationManager) {
|
||||
|
||||
private var calledInitialize = false
|
||||
private var calledClearBackupData = false
|
||||
|
@ -53,6 +55,7 @@ class BackupCoordinator(
|
|||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error initializing device", e)
|
||||
nm.onBackupError()
|
||||
TRANSPORT_ERROR
|
||||
}
|
||||
}
|
||||
|
|
10
app/src/main/res/drawable/ic_cloud_error.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_error.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"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,20H6C2.71,20 0,17.29 0,14C0,10.9 2.34,8.36 5.35,8.03C6.6,5.64 9.11,4 12,4C15.64,4 18.67,6.59 19.35,10.03C21.95,10.22 24,12.36 24,15C24,17.74 21.74,20 19,20M11,15V17H13V15H11M11,13H13V8H11V13Z" />
|
||||
</vector>
|
|
@ -65,4 +65,9 @@
|
|||
<string name="notification_backup_result_rejected">Not backed up</string>
|
||||
<string name="notification_backup_result_error">Backup failed</string>
|
||||
|
||||
<string name="notification_error_channel_title">Error Notification</string>
|
||||
<string name="notification_error_title">Backup Error</string>
|
||||
<string name="notification_error_text">A device backup failed to run.</string>
|
||||
<string name="notification_error_action">Fix</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
|||
import android.app.backup.RestoreDescription
|
||||
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.stevesoltys.backup.BackupNotificationManager
|
||||
import com.stevesoltys.backup.crypto.CipherFactoryImpl
|
||||
import com.stevesoltys.backup.crypto.CryptoImpl
|
||||
import com.stevesoltys.backup.crypto.KeyManagerTestImpl
|
||||
|
@ -37,7 +38,8 @@ 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 backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup)
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
|
||||
|
||||
private val restorePlugin = mockk<RestorePlugin>()
|
||||
private val kvRestorePlugin = mockk<KVRestorePlugin>()
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.backup.transport.backup
|
|||
|
||||
import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import com.stevesoltys.backup.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
|
@ -17,8 +18,9 @@ internal class BackupCoordinatorTest: BackupTest() {
|
|||
private val plugin = mockk<BackupPlugin>()
|
||||
private val kv = mockk<KVBackup>()
|
||||
private val full = mockk<FullBackup>()
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
|
||||
private val backup = BackupCoordinator(plugin, kv, full)
|
||||
private val backup = BackupCoordinator(plugin, kv, full, notificationManager)
|
||||
|
||||
@Test
|
||||
fun `device initialization succeeds and delegates to plugin`() {
|
||||
|
@ -33,6 +35,7 @@ internal class BackupCoordinatorTest: BackupTest() {
|
|||
@Test
|
||||
fun `device initialization fails`() {
|
||||
every { plugin.initializeDevice() } throws IOException()
|
||||
every { notificationManager.onBackupError() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
buildscript {
|
||||
|
||||
ext.kotlin_version = '1.3.41'
|
||||
ext.kotlin_version = '1.3.50'
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
|
|
Loading…
Reference in a new issue