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

View file

@ -15,9 +15,31 @@ sealed class CheckerResult {
) : CheckerResult() ) : CheckerResult()
data class Error( 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 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() 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.GONE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.google.android.material.color.MaterialColors
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
import com.stevesoltys.seedvault.transport.restore.RestorableBackup import com.stevesoltys.seedvault.transport.restore.RestorableBackup
@ -40,6 +42,7 @@ internal class RestoreSetAdapter(
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) { 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 titleView = v.requireViewById<TextView>(R.id.titleView)
private val appView = v.requireViewById<TextView>(R.id.appView) private val appView = v.requireViewById<TextView>(R.id.appView)
private val apkView = v.requireViewById<TextView>(R.id.apkView) private val apkView = v.requireViewById<TextView>(R.id.apkView)
@ -49,6 +52,11 @@ internal class RestoreSetAdapter(
if (listener != null) { if (listener != null) {
v.setOnClickListener { listener.onRestorableBackupClicked(item) } 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 titleView.text = item.name
appView.text = if (item.sizeAppData > 0) { 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) v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
} }
appView.visibility = if (item.numAppData > 0) VISIBLE else GONE 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( v.context.getString(
R.string.restore_restore_set_apks, R.string.restore_restore_set_apks,
item.numApks, item.numApks,
@ -70,7 +80,13 @@ internal class RestoreSetAdapter(
} else { } else {
v.context.getString(R.string.restore_restore_set_apks_no_size, item.numApks) 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) timeView.text = getRelativeTime(item.time)
} }

View file

@ -19,12 +19,14 @@ data class RestorableBackup(
val backupMetadata: BackupMetadata, val backupMetadata: BackupMetadata,
val repoId: String? = null, val repoId: String? = null,
val snapshot: Snapshot? = 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), backupMetadata = BackupMetadata.fromSnapshot(snapshot),
repoId = repoId, repoId = repoId,
snapshot = snapshot, snapshot = snapshot,
canBeRestored = canBeRestored,
) )
val name: String val name: String

View file

@ -7,6 +7,8 @@ package com.stevesoltys.seedvault.ui.check
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.View.GONE
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -52,10 +54,7 @@ class AppCheckResultActivity : BackupActivity() {
private fun onActionReceived() { private fun onActionReceived() {
when (val result = checker.checkerResult) { when (val result = checker.checkerResult) {
is CheckerResult.Success -> onSuccess(result) is CheckerResult.Success -> onSuccess(result)
is CheckerResult.Error -> { is CheckerResult.Error -> onError(result)
// TODO
log.info { "snapshots: ${result.snapshots.size}, errors: ${result.errors.size}" }
}
is CheckerResult.GeneralError, null -> { is CheckerResult.GeneralError, null -> {
// TODO // TODO
if (result == null) log.error { "No more result" } if (result == null) log.error { "No more result" }
@ -66,7 +65,7 @@ class AppCheckResultActivity : BackupActivity() {
} }
private fun onSuccess(result: CheckerResult.Success) { private fun onSuccess(result: CheckerResult.Success) {
setContentView(R.layout.activity_check_success) setContentView(R.layout.activity_check_result)
val intro = getString( val intro = getString(
R.string.backup_app_check_success_intro, R.string.backup_app_check_success_intro,
result.snapshots.size, 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, size),
"${formatShortFileSize(context, speed)}/s", "${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() // the background activity launch (BAL) gets restricted for setDeleteIntent()
// if we don't use these special ActivityOptions, may cause issues in future SDKs // if we don't use these special ActivityOptions, may cause issues in future SDKs
val options = ActivityOptions.makeBasic() 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 deleteIntent = getActivity(context, 2, dIntent, FLAG_IMMUTABLE, options)
val actionTitle = context.getString(R.string.notification_checking_action) val actionTitle = context.getString(R.string.notification_checking_action)
val action = Action.Builder(null, actionTitle, contentIntent).build() val action = Action.Builder(null, actionTitle, contentIntent).build()
val notification = Builder(context, CHANNEL_ID_CHECKING) return Builder(context, CHANNEL_ID_CHECKING)
.setContentTitle(context.getString(R.string.notification_checking_finished_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_cloud_done)
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
.addAction(action) .addAction(action)
.setDeleteIntent(deleteIntent) .setDeleteIntent(deleteIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build()
nm.cancel(NOTIFICATION_ID_CHECKING)
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
} }
fun onCheckCompleteNotificationSeen() { fun onCheckCompleteNotificationSeen() {

View file

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

View file

@ -191,11 +191,17 @@
<string name="notification_checking_title">Checking app backups…</string> <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_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_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="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_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_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_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 --> <!-- 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_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">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_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">Don\'t restore</string>
<string name="restore_skip_apps">Skip restoring apps</string> <string name="restore_skip_apps">Skip restoring apps</string>
<string name="restore_invalid_location_title">No backups found</string> <string name="restore_invalid_location_title">No backups found</string>