diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f83eb06e..8784f83a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -131,6 +131,11 @@
+
+
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
index 2ff2c21e..d793addf 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
@@ -27,7 +27,6 @@ import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.roundToLong
-@WorkerThread
internal class Checker(
private val crypto: Crypto,
private val backendManager: BackendManager,
@@ -43,7 +42,10 @@ internal class Checker(
// TODO determine also based on backendManager
return Runtime.getRuntime().availableProcessors()
}
+ var checkerResult: CheckerResult? = null
+ private set
+ @WorkerThread
suspend fun getBackupSize(): Long {
// get all snapshots
val folder = TopLevelFolder(crypto.repoId)
@@ -63,6 +65,7 @@ internal class Checker(
return sizeMap.values.sumOf { it.toLong() }
}
+ @WorkerThread
suspend fun check(percent: Int) {
check(percent in 0..100) { "Percent $percent out of bounds." }
@@ -106,6 +109,14 @@ internal class Checker(
val passedTime = System.currentTimeMillis() - startTime
val bandwidth = size.get() / (passedTime.toDouble() / 1000).roundToLong()
nm.onCheckComplete(size.get(), bandwidth)
+ checkerResult = CheckerResult.Success(snapshots, percent, size.get())
+ this.snapshots = null
+ }
+
+ fun clear() {
+ log.info { "Clearing..." }
+ snapshots = null
+ checkerResult = null
}
private fun getBlobSample(snapshots: List, percent: Int): Map {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/CheckerResult.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/CheckerResult.kt
new file mode 100644
index 00000000..56b9b0b3
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/CheckerResult.kt
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault.repo
+
+import com.stevesoltys.seedvault.proto.Snapshot
+
+sealed class CheckerResult {
+ data class Success(
+ val snapshots: List,
+ val percent: Int,
+ val size: Long,
+ ) : CheckerResult()
+
+ data class Error(
+ val snapshots: List,
+ val errors: Map,
+ ) : CheckerResult()
+
+ data class GeneralError(val e: Exception) : CheckerResult()
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
index bf89c92e..05e4476e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
@@ -22,7 +22,7 @@ import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
internal class RestoreSetAdapter(
- private val listener: RestorableBackupClickListener,
+ private val listener: RestorableBackupClickListener?,
private val items: List,
) : Adapter() {
@@ -46,7 +46,9 @@ internal class RestoreSetAdapter(
private val timeView = v.requireViewById(R.id.timeView)
internal fun bind(item: RestorableBackup) {
- v.setOnClickListener { listener.onRestorableBackupClicked(item) }
+ if (listener != null) {
+ v.setOnClickListener { listener.onRestorableBackupClicked(item) }
+ }
titleView.text = item.name
appView.text = if (item.sizeAppData > 0) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckResultActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckResultActivity.kt
new file mode 100644
index 00000000..700d81f3
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckResultActivity.kt
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault.ui.check
+
+import android.os.Bundle
+import android.text.format.Formatter.formatShortFileSize
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.stevesoltys.seedvault.R
+import com.stevesoltys.seedvault.repo.Checker
+import com.stevesoltys.seedvault.repo.CheckerResult
+import com.stevesoltys.seedvault.restore.RestoreSetAdapter
+import com.stevesoltys.seedvault.transport.restore.RestorableBackup
+import com.stevesoltys.seedvault.ui.BackupActivity
+import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.koin.android.ext.android.inject
+
+internal const val ACTION_FINISHED = "FINISHED"
+internal const val ACTION_SHOW = "SHOW"
+
+class AppCheckResultActivity : BackupActivity() {
+
+ private val log = KotlinLogging.logger { }
+
+ private val checker: Checker by inject()
+ private val notificationManager: BackupNotificationManager by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (savedInstanceState == null) when (intent.action) {
+ ACTION_FINISHED -> {
+ notificationManager.onCheckCompleteNotificationSeen()
+ checker.clear()
+ finish()
+ }
+ ACTION_SHOW -> {
+ notificationManager.onCheckCompleteNotificationSeen()
+ onActionReceived()
+ }
+ else -> {
+ log.error { "Unknown action: ${intent.action}" }
+ finish()
+ }
+ }
+ }
+
+ private fun onActionReceived() {
+ when (val result = checker.checkerResult) {
+ is CheckerResult.Success -> onSuccess(result)
+ is CheckerResult.Error -> {
+ // TODO
+ log.info { "snapshots: ${result.snapshots.size}, errors: ${result.errors.size}" }
+ }
+ is CheckerResult.GeneralError, null -> {
+ // TODO
+ if (result == null) log.error { "No more result" }
+ else log.info((result as CheckerResult.GeneralError).e) { "Error: " }
+ }
+ }
+ checker.clear()
+ }
+
+ private fun onSuccess(result: CheckerResult.Success) {
+ setContentView(R.layout.activity_check_success)
+ val intro = getString(
+ R.string.backup_app_check_success_intro,
+ result.snapshots.size,
+ result.percent,
+ formatShortFileSize(this, result.size),
+ )
+ requireViewById(R.id.introView).text = intro
+
+ val listView = requireViewById(R.id.listView)
+ listView.adapter = RestoreSetAdapter(
+ listener = null,
+ items = result.snapshots.map { snapshot ->
+ RestorableBackup("", snapshot)
+ }.sortedByDescending { it.time },
+ )
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
index 8aaf5ec0..4a83f5b6 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
@@ -6,6 +6,8 @@
package com.stevesoltys.seedvault.ui.notification
import android.annotation.SuppressLint
+import android.app.ActivityOptions
+import android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -15,8 +17,10 @@ import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.app.PendingIntent.getActivity
import android.content.Context
import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager.NameNotFoundException
import android.text.format.Formatter.formatShortFileSize
import android.util.Log
@@ -34,6 +38,9 @@ import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity
+import com.stevesoltys.seedvault.ui.check.ACTION_FINISHED
+import com.stevesoltys.seedvault.ui.check.ACTION_SHOW
+import com.stevesoltys.seedvault.ui.check.AppCheckResultActivity
import kotlin.math.min
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
@@ -202,7 +209,7 @@ internal class BackupNotificationManager(private val context: Context) {
val intent = Intent(context, SettingsActivity::class.java).apply {
action = ACTION_APP_STATUS_LIST
}
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
+ val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
setSmallIcon(R.drawable.ic_cloud_done)
setContentTitle(context.getString(R.string.notification_success_title))
@@ -221,7 +228,7 @@ internal class BackupNotificationManager(private val context: Context) {
fun onBackupError() {
val intent = Intent(context, SettingsActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
+ val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
setSmallIcon(R.drawable.ic_cloud_error)
setContentTitle(context.getString(R.string.notification_failed_title))
@@ -241,7 +248,7 @@ internal class BackupNotificationManager(private val context: Context) {
@SuppressLint("RestrictedApi")
fun onFixableBackupError() {
val intent = Intent(context, SettingsActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
+ val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
val actionText = context.getString(R.string.notification_error_action)
val action = Action(R.drawable.ic_storage, actionText, pendingIntent)
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
@@ -276,7 +283,7 @@ internal class BackupNotificationManager(private val context: Context) {
fun getRestoreNotification() = Notification.Builder(context, CHANNEL_ID_RESTORE).apply {
val intent = Intent(context, RestoreActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
+ val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
setContentIntent(pendingIntent)
setSmallIcon(R.drawable.ic_cloud_restore)
setContentTitle(context.getString(R.string.notification_restore_title))
@@ -356,19 +363,45 @@ internal class BackupNotificationManager(private val context: Context) {
formatShortFileSize(context, size),
"${formatShortFileSize(context, speed)}/s",
)
+ // the background activity launch (BAL) gets restricted for setDeleteIntent()
+ // if we don't use these special ActivityOptions, may cause issues in future SDKs
+ val options = ActivityOptions.makeBasic()
+ .setPendingIntentCreatorBackgroundActivityStartMode(
+ MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+ ).toBundle()
+ val cIntent = Intent(context, AppCheckResultActivity::class.java).apply {
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ setAction(ACTION_SHOW)
+ }
+ val dIntent = Intent(context, AppCheckResultActivity::class.java).apply {
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ setAction(ACTION_FINISHED)
+ }
+ val contentIntent = getActivity(context, 1, cIntent, FLAG_IMMUTABLE, options)
+ val deleteIntent = getActivity(context, 2, dIntent, FLAG_IMMUTABLE, options)
+ val actionTitle = context.getString(R.string.notification_checking_action)
+ val action = Action.Builder(null, actionTitle, contentIntent).build()
val notification = Builder(context, CHANNEL_ID_CHECKING)
.setContentTitle(context.getString(R.string.notification_checking_finished_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_cloud_done)
+ .setContentIntent(contentIntent)
+ .addAction(action)
+ .setDeleteIntent(deleteIntent)
+ .setAutoCancel(true)
.build()
nm.cancel(NOTIFICATION_ID_CHECKING)
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
}
+ fun onCheckCompleteNotificationSeen() {
+ nm.cancel(NOTIFICATION_ID_CHECK_FINISHED)
+ }
+
@SuppressLint("RestrictedApi")
fun onNoMainKeyError() {
val intent = Intent(context, SettingsActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
+ val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
val actionText = context.getString(R.string.notification_error_action)
val action = Action(0, actionText, pendingIntent)
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
diff --git a/app/src/main/res/layout/activity_check_success.xml b/app/src/main/res/layout/activity_check_success.xml
new file mode 100644
index 00000000..c72f2494
--- /dev/null
+++ b/app/src/main/res/layout/activity_check_success.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ef80eda9..f084a1ba 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -189,8 +189,13 @@
App backup integrity check
Checking app backups…
- App backup integrity confirmed
+ App backup integrity verified
Successfully checked %1$s at an average speed of %2$s.
+ Details
+
+ %1$d snapshots were found and %2$d%% of their data (%3$s) successfully verified:
+ Note: We can not verify whether apps include all of their data in the backup.
+ We could not find any backup. Please run a successful backup first and then try checking again.