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:
Torsten Grote 2019-09-02 17:01:12 -03:00
parent 683268a15f
commit c714a4e7e1
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
12 changed files with 156 additions and 54 deletions

View file

@ -5,6 +5,7 @@ import android.app.Application
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import android.util.Log import android.util.Log
@ -14,6 +15,8 @@ import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.settings.setDeviceName import com.stevesoltys.backup.settings.setDeviceName
import io.github.novacrypto.hashing.Sha256.sha256Twice 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 private val TAG = Backup::class.java.simpleName
/** /**
@ -31,6 +34,10 @@ class Backup : Application() {
} }
} }
val notificationManager by lazy {
BackupNotificationManager(this)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
storeDeviceName() storeDeviceName()
@ -53,3 +60,5 @@ class Backup : Application() {
} }
} }
fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE

View file

@ -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)
}
}

View file

@ -1,40 +1,18 @@
package com.stevesoltys.backup 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.BackupProgress
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.IBackupObserver import android.app.backup.IBackupObserver
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.util.Log.INFO import android.util.Log.INFO
import android.util.Log.isLoggable 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 private val TAG = NotificationBackupObserver::class.java.simpleName
class NotificationBackupObserver( class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub() {
private val context: Context,
private val userInitiated: Boolean) : IBackupObserver.Stub() {
private val pm = context.packageManager private val pm = context.packageManager
private val nm = context.getSystemService(NotificationManager::class.java).apply { private val nm = (context.applicationContext as Backup).notificationManager
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
}
/** /**
* This method could be called several times for packages with full data backup. * 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. * @param backupProgress Current progress of backup for the package.
*/ */
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
val transferred = backupProgress.bytesTransferred val transferred = backupProgress.bytesTransferred.toInt()
val expected = backupProgress.bytesExpected val expected = backupProgress.bytesExpected.toInt()
if (isLoggable(TAG, INFO)) { if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected") Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected")
} }
val notification = notificationBuilder.apply { val app = getAppName(currentBackupPackage)
setContentTitle(context.getString(R.string.notification_title)) nm.onBackupUpdate(app, transferred, expected, userInitiated)
setContentText(getAppName(currentBackupPackage))
setProgress(expected.toInt(), transferred.toInt(), false)
}.build()
nm.notify(NOTIFICATION_ID, notification)
} }
/** /**
@ -71,16 +45,7 @@ class NotificationBackupObserver(
if (isLoggable(TAG, INFO)) { if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Completed. Target: $target, status: $status") Log.i(TAG, "Completed. Target: $target, status: $status")
} }
val title = context.getString(when (status) { nm.onBackupResult(getAppName(target), status, userInitiated)
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)
} }
/** /**
@ -94,7 +59,7 @@ class NotificationBackupObserver(
if (isLoggable(TAG, INFO)) { if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Backup finished. Status: $status") Log.i(TAG, "Backup finished. Status: $status")
} }
nm.cancel(NOTIFICATION_ID) nm.onBackupFinished()
} }
private fun getAppName(packageId: String): CharSequence { private fun getAppName(packageId: String): CharSequence {

View file

@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEventHandler import com.stevesoltys.backup.LiveEventHandler
import com.stevesoltys.backup.R import com.stevesoltys.backup.R
@ -25,8 +26,8 @@ class SettingsActivity : AppCompatActivity() {
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java) viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore -> viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
if (wasEmptyBefore) showFragment(SettingsFragment()) if (initialSetUp) showFragment(SettingsFragment())
else supportFragmentManager.popBackStack() else supportFragmentManager.popBackStack()
}) })
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show -> viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
@ -54,8 +55,10 @@ class SettingsActivity : AppCompatActivity() {
// check that backup is provisioned // check that backup is provisioned
if (!viewModel.recoveryCodeIsSet()) { if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity() showRecoveryCodeActivity()
} else if (!viewModel.locationIsSet()) { } else if (!viewModel.validLocationIsSet()) {
showFragment(BackupLocationFragment()) showFragment(BackupLocationFragment())
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
} }
} }

View file

@ -5,10 +5,12 @@ import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.LiveEvent
import com.stevesoltys.backup.MutableLiveEvent import com.stevesoltys.backup.MutableLiveEvent
import com.stevesoltys.backup.isOnExternalStorage
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.transport.requestBackup import com.stevesoltys.backup.transport.requestBackup
@ -30,7 +32,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey() 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?) { fun handleChooseFolderResult(result: Intent?) {
val folderUri = result?.data ?: return val folderUri = result?.data ?: return
@ -40,13 +48,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags) app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
// check if this is initial set-up or a later change // 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 // store backup folder location in settings
setBackupFolderUri(app, folderUri) setBackupFolderUri(app, folderUri)
// notify the UI that the location has been set // 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 // stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java)) app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))

View file

@ -36,8 +36,9 @@ class PluginManager(context: Context) {
private val inputFactory = InputFactory() private val inputFactory = InputFactory()
private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto) private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto)
private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, 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) private val restorePlugin = DocumentsProviderRestorePlugin(storage)

View file

@ -5,6 +5,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.backup.BackupNotificationManager
import java.io.IOException import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName private val TAG = BackupCoordinator::class.java.simpleName
@ -16,7 +17,8 @@ private val TAG = BackupCoordinator::class.java.simpleName
class BackupCoordinator( class BackupCoordinator(
private val plugin: BackupPlugin, private val plugin: BackupPlugin,
private val kv: KVBackup, private val kv: KVBackup,
private val full: FullBackup) { private val full: FullBackup,
private val nm: BackupNotificationManager) {
private var calledInitialize = false private var calledInitialize = false
private var calledClearBackupData = false private var calledClearBackupData = false
@ -53,6 +55,7 @@ class BackupCoordinator(
TRANSPORT_OK TRANSPORT_OK
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
nm.onBackupError()
TRANSPORT_ERROR TRANSPORT_ERROR
} }
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
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>

View file

@ -65,4 +65,9 @@
<string name="notification_backup_result_rejected">Not backed up</string> <string name="notification_backup_result_rejected">Not backed up</string>
<string name="notification_backup_result_error">Backup failed</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> </resources>

View file

@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.crypto.KeyManagerTestImpl import com.stevesoltys.backup.crypto.KeyManagerTestImpl
@ -37,7 +38,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val fullBackupPlugin = mockk<FullBackupPlugin>() private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) 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 restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>() private val kvRestorePlugin = mockk<KVRestorePlugin>()

View file

@ -2,6 +2,7 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
@ -17,8 +18,9 @@ internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk<BackupPlugin>() private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>() private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>() 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 @Test
fun `device initialization succeeds and delegates to plugin`() { fun `device initialization succeeds and delegates to plugin`() {
@ -33,6 +35,7 @@ internal class BackupCoordinatorTest: BackupTest() {
@Test @Test
fun `device initialization fails`() { fun `device initialization fails`() {
every { plugin.initializeDevice() } throws IOException() every { plugin.initializeDevice() } throws IOException()
every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())

View file

@ -2,7 +2,7 @@
buildscript { buildscript {
ext.kotlin_version = '1.3.41' ext.kotlin_version = '1.3.50'
repositories { repositories {
jcenter() jcenter()