Show app backup check error screen

and error notification
This commit is contained in:
Torsten Grote 2024-10-28 15:07:02 -03:00
parent 26063a8ef0
commit 83974b4121
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
8 changed files with 147 additions and 24 deletions

View file

@ -22,7 +22,9 @@ import org.calyxos.seedvault.core.toHexString
import java.security.DigestInputStream
import java.security.GeneralSecurityException
import java.security.MessageDigest
import java.util.concurrent.ConcurrentSkipListSet
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.roundToLong
@ -36,6 +38,7 @@ internal class Checker(
) {
private val log = KotlinLogging.logger { }
private var handleSize: Int? = null
private var snapshots: List<Snapshot>? = null
private val concurrencyLimit: Int
get() {
@ -55,6 +58,7 @@ internal class Checker(
}
val snapshots = snapshotManager.onSnapshotsLoaded(handles)
this.snapshots = snapshots // remember loaded snapshots
this.handleSize = handles.size // remember number of snapshot handles we had
// get total disk space used by snapshots
val sizeMap = mutableMapOf<String, Int>()
@ -71,6 +75,10 @@ internal class Checker(
if (snapshots == null) getBackupSize() // just get size again to be sure we get snapshots
val snapshots = snapshots ?: error("Snapshots still null")
val handleSize = handleSize ?: error("Handle size still null")
check(handleSize >= snapshots.size) {
"Got $handleSize handles, but ${snapshots.size} snapshots."
}
val blobSample = getBlobSample(snapshots, percent)
val sampleSize = blobSample.values.sumOf { it.length.toLong() }
log.info { "Blob sample has ${blobSample.size} blobs worth $sampleSize bytes." }
@ -78,6 +86,7 @@ internal class Checker(
// check blobs concurrently
val semaphore = Semaphore(concurrencyLimit)
val size = AtomicLong()
val badChunks = ConcurrentSkipListSet<String>()
val lastNotification = AtomicLong()
val startTime = System.currentTimeMillis()
coroutineScope {
@ -86,8 +95,13 @@ internal class Checker(
launch {
// suspend here until we get a permit from the semaphore (there's free workers)
semaphore.withPermit {
// TODO record errors
checkBlob(chunkId, blob)
try {
checkBlob(chunkId, blob)
} catch (e: Exception) {
log.error(e) { "Error loading chunk $chunkId: " }
// TODO we could try differentiating transient backend issues
badChunks.add(chunkId)
}
}
// keep track of how much we checked and for how long
val newSize = size.addAndGet(blob.length.toLong())
@ -106,10 +120,15 @@ internal class Checker(
if (sampleSize != size.get()) log.error {
"Checked ${size.get()} bytes, but expected $sampleSize"
}
val passedTime = System.currentTimeMillis() - startTime
val passedTime = max(System.currentTimeMillis() - startTime, 1000) // no div by zero
val bandwidth = size.get() / (passedTime.toDouble() / 1000).roundToLong()
nm.onCheckComplete(size.get(), bandwidth)
checkerResult = CheckerResult.Success(snapshots, percent, size.get())
checkerResult = if (badChunks.isEmpty() && handleSize == snapshots.size && handleSize > 0) {
nm.onCheckComplete(size.get(), bandwidth)
CheckerResult.Success(snapshots, percent, size.get())
} else {
nm.onCheckFinishedWithError(size.get(), bandwidth)
CheckerResult.Error(handleSize, snapshots, badChunks)
}
this.snapshots = null
}

View file

@ -15,9 +15,31 @@ sealed class CheckerResult {
) : CheckerResult()
data class Error(
/**
* This number is greater than the size of [snapshots],
* if we could not read/decrypt one or more snapshots.
*/
val existingSnapshots: Int,
val snapshots: List<Snapshot>,
val errors: Map<String, Exception>,
) : CheckerResult()
/**
* The list of chunkIDs that had errors.
*/
val errorChunkIds: Set<String>,
) : CheckerResult() {
val goodSnapshots: List<Snapshot>
val badSnapshots: List<Snapshot>
init {
val good = mutableListOf<Snapshot>()
val bad = mutableListOf<Snapshot>()
snapshots.forEach { snapshot ->
val isGood = snapshot.blobsMap.keys.intersect(errorChunkIds).isEmpty()
if (isGood) good.add(snapshot) else bad.add(snapshot)
}
goodSnapshots = good
badSnapshots = bad
}
}
data class GeneralError(val e: Exception) : CheckerResult()
}

View file

@ -14,9 +14,11 @@ import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.google.android.material.color.MaterialColors
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
@ -40,6 +42,7 @@ internal class RestoreSetAdapter(
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
private val imageView = v.requireViewById<ImageView>(R.id.imageView)
private val titleView = v.requireViewById<TextView>(R.id.titleView)
private val appView = v.requireViewById<TextView>(R.id.appView)
private val apkView = v.requireViewById<TextView>(R.id.apkView)
@ -49,6 +52,11 @@ internal class RestoreSetAdapter(
if (listener != null) {
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
}
if (item.canBeRestored) {
imageView.setImageResource(R.drawable.ic_phone_android)
} else {
imageView.setImageResource(R.drawable.ic_error_red)
}
titleView.text = item.name
appView.text = if (item.sizeAppData > 0) {
@ -61,7 +69,9 @@ internal class RestoreSetAdapter(
v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
}
appView.visibility = if (item.numAppData > 0) VISIBLE else GONE
apkView.text = if (item.sizeApks > 0) {
apkView.text = if (!item.canBeRestored) {
v.context.getString(R.string.restore_restore_set_can_not_get_restored)
} else if (item.sizeApks > 0) {
v.context.getString(
R.string.restore_restore_set_apks,
item.numApks,
@ -70,7 +80,13 @@ internal class RestoreSetAdapter(
} else {
v.context.getString(R.string.restore_restore_set_apks_no_size, item.numApks)
}
apkView.visibility = if (item.numApks > 0) VISIBLE else GONE
apkView.visibility = if (item.numApks > 0 || !item.canBeRestored) VISIBLE else GONE
val apkTextColor = if (item.canBeRestored) {
appView.currentTextColor
} else {
MaterialColors.getColor(apkView, R.attr.colorError)
}
apkView.setTextColor(apkTextColor)
timeView.text = getRelativeTime(item.time)
}

View file

@ -19,12 +19,14 @@ data class RestorableBackup(
val backupMetadata: BackupMetadata,
val repoId: String? = null,
val snapshot: Snapshot? = null,
val canBeRestored: Boolean = true,
) {
constructor(repoId: String, snapshot: Snapshot) : this(
constructor(repoId: String, snapshot: Snapshot, canBeRestored: Boolean = true) : this(
backupMetadata = BackupMetadata.fromSnapshot(snapshot),
repoId = repoId,
snapshot = snapshot,
canBeRestored = canBeRestored,
)
val name: String

View file

@ -7,6 +7,8 @@ package com.stevesoltys.seedvault.ui.check
import android.os.Bundle
import android.text.format.Formatter.formatShortFileSize
import android.view.View.GONE
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
@ -52,10 +54,7 @@ class AppCheckResultActivity : BackupActivity() {
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.Error -> onError(result)
is CheckerResult.GeneralError, null -> {
// TODO
if (result == null) log.error { "No more result" }
@ -66,7 +65,7 @@ class AppCheckResultActivity : BackupActivity() {
}
private fun onSuccess(result: CheckerResult.Success) {
setContentView(R.layout.activity_check_success)
setContentView(R.layout.activity_check_result)
val intro = getString(
R.string.backup_app_check_success_intro,
result.snapshots.size,
@ -84,4 +83,42 @@ class AppCheckResultActivity : BackupActivity() {
)
}
private fun onError(result: CheckerResult.Error) {
setContentView(R.layout.activity_check_result)
requireViewById<ImageView>(R.id.imageView).setImageResource(R.drawable.ic_cloud_error)
requireViewById<TextView>(R.id.titleView).setText(R.string.backup_app_check_error_title)
val disclaimerView = requireViewById<TextView>(R.id.disclaimerView)
val intro = if (result.existingSnapshots == 0) {
disclaimerView.visibility = GONE
getString(R.string.backup_app_check_error_no_snapshots)
} else if (result.snapshots.isEmpty()) {
disclaimerView.visibility = GONE
getString(
R.string.backup_app_check_error_only_broken_snapshots,
result.existingSnapshots,
)
} else if (result.existingSnapshots > result.snapshots.size) {
getString(
R.string.backup_app_check_error_some_snapshots,
result.existingSnapshots,
result.snapshots.size,
)
} else {
getString(R.string.backup_app_check_error_read_all_snapshots, result.snapshots.size)
}
requireViewById<TextView>(R.id.introView).text = intro
val items = (result.goodSnapshots.map { snapshot ->
RestorableBackup("", snapshot)
} + result.badSnapshots.map { snapshot ->
RestorableBackup("", snapshot, false)
}).sortedByDescending { it.time }
val listView = requireViewById<RecyclerView>(R.id.listView)
listView.adapter = RestoreSetAdapter(
listener = null,
items = items,
)
}
}

View file

@ -363,6 +363,31 @@ internal class BackupNotificationManager(private val context: Context) {
formatShortFileSize(context, size),
"${formatShortFileSize(context, speed)}/s",
)
val notification = getOnCheckFinishedBuilder()
.setContentTitle(context.getString(R.string.notification_checking_finished_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_cloud_done)
.build()
nm.cancel(NOTIFICATION_ID_CHECKING)
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
}
fun onCheckFinishedWithError(size: Long, speed: Long) {
val text = context.getString(
R.string.notification_checking_error_text,
formatShortFileSize(context, size),
"${formatShortFileSize(context, speed)}/s",
)
val notification = getOnCheckFinishedBuilder()
.setContentTitle(context.getString(R.string.notification_checking_error_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_cloud_error)
.build()
nm.cancel(NOTIFICATION_ID_CHECKING)
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
}
private fun getOnCheckFinishedBuilder(): Builder {
// 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()
@ -381,17 +406,11 @@ internal class BackupNotificationManager(private val context: Context) {
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)
return Builder(context, CHANNEL_ID_CHECKING)
.setContentIntent(contentIntent)
.addAction(action)
.setDeleteIntent(deleteIntent)
.setAutoCancel(true)
.build()
nm.cancel(NOTIFICATION_ID_CHECKING)
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
}
fun onCheckCompleteNotificationSeen() {

View file

@ -51,10 +51,10 @@
android:layout_height="wrap_content"
android:layout_margin="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/disclaimerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/introView"
app:layout_constraintVertical_bias="0.0"
tools:itemCount="4"
tools:listitem="@layout/list_item_restore_set" />
@ -70,7 +70,8 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/listView" />
app:layout_constraintTop_toBottomOf="@+id/listView"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -191,11 +191,17 @@
<string name="notification_checking_title">Checking app backups…</string>
<string name="notification_checking_finished_title">App backup integrity verified</string>
<string name="notification_checking_finished_text">Successfully checked %1$s at an average speed of %2$s.</string>
<string name="notification_checking_error_title">Backup errors found</string>
<string name="notification_checking_error_text">Tap for details. We checked %1$s at an average speed of %2$s.</string>
<string name="notification_checking_action">Details</string>
<string name="backup_app_check_success_intro">%1$d snapshots were found and %2$d%% of their data (%3$s) successfully verified:</string>
<string name="backup_app_check_success_disclaimer">Note: We can not verify whether apps include all of their data in the backup.</string>
<string name="backup_app_check_error_title">@string/notification_checking_error_title</string>
<string name="backup_app_check_error_no_snapshots">We could not find any backup. Please run a successful backup first and then try checking again.</string>
<string name="backup_app_check_error_only_broken_snapshots">We found %1$d backup snapshots. However, all of them were corrupted. Please run a successful backup and then try checking again.</string>
<string name="backup_app_check_error_some_snapshots">We found %1$d backup snapshots. However, we could only read the following %2$d snapshots.</string>
<string name="backup_app_check_error_read_all_snapshots">We found %1$d backup snapshots. However, some had errors and can not be fully restored.</string>
<!-- App Backup and Restore State -->
@ -229,6 +235,7 @@
<string name="restore_restore_set_apps_no_size">Has user data for <xliff:g example="42" id="apps">%1$d</xliff:g> apps</string>
<string name="restore_restore_set_apks">Contains <xliff:g example="42" id="apps">%1$d</xliff:g> apps (<xliff:g example="1 GB" id="size">%2$s</xliff:g>)</string>
<string name="restore_restore_set_apks_no_size">Contains <xliff:g example="42" id="apps">%1$d</xliff:g> apps</string>
<string name="restore_restore_set_can_not_get_restored">Can not be (fully) restored.</string>
<string name="restore_skip">Don\'t restore</string>
<string name="restore_skip_apps">Skip restoring apps</string>
<string name="restore_invalid_location_title">No backups found</string>