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.