diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt
index a71caef0..1efe3c72 100644
--- a/app/src/main/java/com/stevesoltys/backup/Backup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt
new file mode 100644
index 00000000..51b9317e
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt
@@ -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)
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
index 8729e623..de510f80 100644
--- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
+++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
index 18a46926..36f52c99 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
@@ -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()
}
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
index 7ba92a1c..6840dabd 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
@@ -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))
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
index 1aeb85d4..0346049c 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
index 65ad91b0..a4d357f3 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
@@ -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
}
}
diff --git a/app/src/main/res/drawable/ic_cloud_error.xml b/app/src/main/res/drawable/ic_cloud_error.xml
new file mode 100644
index 00000000..ecb6ff7d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_error.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1ac6ec6d..dce1cdd0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -65,4 +65,9 @@
Not backed up
Backup failed
+ Error Notification
+ Backup Error
+ A device backup failed to run.
+ Fix
+
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
index 7875db5e..2f9ceb3c 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
@@ -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()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
- private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup)
+ private val notificationManager = mockk()
+ private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
private val restorePlugin = mockk()
private val kvRestorePlugin = mockk()
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
index e5833277..8e8b38fb 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
@@ -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()
private val kv = mockk()
private val full = mockk()
+ private val notificationManager = mockk()
- 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())
diff --git a/build.gradle b/build.gradle
index 26dd7758..84707d92 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,7 +2,7 @@
buildscript {
- ext.kotlin_version = '1.3.41'
+ ext.kotlin_version = '1.3.50'
repositories {
jcenter()