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/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index eac57f83..02b31b0c 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -90,6 +90,7 @@ open class App : Application() {
storageBackup = get(),
backupManager = get(),
backupStateManager = get(),
+ checker = get(),
)
}
viewModel {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt
index f9028dbe..de1254f2 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt
@@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
+import com.stevesoltys.seedvault.worker.AppCheckerWorker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -32,18 +33,28 @@ class BackupStateManager(
flow = ConfigurableBackupTransportService.isRunning,
flow2 = StorageBackupService.isRunning,
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
- flow4 = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
- ) { appBackupRunning, filesBackupRunning, workInfo1, workInfo2 ->
+ ) { appBackupRunning, filesBackupRunning, workInfo1 ->
val workInfoState1 = workInfo1.getOrNull(0)?.state
- val workInfoState2 = workInfo2.getOrNull(0)?.state
Log.i(
TAG, "appBackupRunning: $appBackupRunning, " +
"filesBackupRunning: $filesBackupRunning, " +
- "appBackupWorker: ${workInfoState1?.name}, " +
- "pruneBackupWorker: ${workInfoState2?.name}"
+ "appBackupWorker: ${workInfoState1?.name}"
)
- appBackupRunning || filesBackupRunning ||
- workInfoState1 == RUNNING || workInfoState2 == RUNNING
+ appBackupRunning || filesBackupRunning || workInfoState1 == RUNNING
+ }
+
+ val isCheckOrPruneRunning: Flow = combine(
+ flow = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
+ flow2 = workManager.getWorkInfosForUniqueWorkFlow(AppCheckerWorker.UNIQUE_WORK_NAME),
+ ) { pruneInfo, checkInfo ->
+ val pruneInfoState = pruneInfo.getOrNull(0)?.state
+ val checkInfoState = checkInfo.getOrNull(0)?.state
+ Log.i(
+ TAG,
+ "pruneBackupWorker: ${pruneInfoState?.name}, " +
+ "appCheckerWorker: ${checkInfoState?.name}"
+ )
+ pruneInfoState == RUNNING || checkInfoState == RUNNING
}
val isAutoRestoreEnabled: Boolean
diff --git a/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt
index ebc6d57e..1a7c9fe0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt
@@ -43,6 +43,7 @@ class BackendManager(
return mBackendProperties
}
val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true
+ val requiresNetwork: Boolean get() = backendProperties?.requiresNetwork == true
init {
when (settingsManager.storagePluginType) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
index 16085512..76b71821 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
@@ -7,7 +7,10 @@ package com.stevesoltys.seedvault.repo
import android.content.Context
import android.content.Context.MODE_APPEND
+import android.content.Context.MODE_PRIVATE
+import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
+import com.google.protobuf.ByteString
import com.stevesoltys.seedvault.MemoryLogger
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.proto.Snapshot.Blob
@@ -18,7 +21,18 @@ import org.calyxos.seedvault.core.toHexString
import java.io.FileNotFoundException
import java.io.IOException
-private const val CACHE_FILE_NAME = "blobsCache"
+@VisibleForTesting
+internal const val CACHE_FILE_NAME = "blobsCache"
+
+/**
+ * The filename of the file where we store which blobs are known to be corrupt
+ * and should not be used anymore.
+ * Each [BLOB_ID_SIZE] bytes are appended without separator or line breaks.
+ */
+@VisibleForTesting
+internal const val DO_NOT_USE_FILE_NAME = "doNotUseBlobs"
+
+private const val BLOB_ID_SIZE = 32
/**
* Responsible for caching blobs during a backup run,
@@ -45,14 +59,20 @@ class BlobCache(
blobMap.clear()
MemoryLogger.log()
// create map of blobId to size of blob on backend
- val blobIds = blobs.associate {
+ val allowedBlobIds = blobs.associate {
Pair(it.fileHandle.name, it.size.toInt())
+ }.toMutableMap()
+ // remove known bad blob IDs from allowedBlobIds
+ getDoNotUseBlobIds().forEach { knownBadId ->
+ if (allowedBlobIds.remove(knownBadId) != null) {
+ log.info { "Removed known bad blob: $knownBadId" }
+ }
}
// load local blob cache and include only blobs on backend
- loadPersistentBlobCache(blobIds)
+ loadPersistentBlobCache(allowedBlobIds)
// build up mapping from chunkId to blob from available snapshots
snapshots.forEach { snapshot ->
- onSnapshotLoaded(snapshot, blobIds)
+ onSnapshotLoaded(snapshot, allowedBlobIds)
}
MemoryLogger.log()
}
@@ -62,8 +82,21 @@ class BlobCache(
*/
operator fun get(chunkId: String): Blob? = blobMap[chunkId]
+ /**
+ * Should only be called after [populateCache] has returned.
+ *
+ * @return true if all [chunkIds] are in cache, or false if one or more is missing.
+ */
+ fun containsAll(chunkIds: List): Boolean = chunkIds.all { chunkId ->
+ blobMap.containsKey(chunkId)
+ }
+
/**
* Should get called for all new blobs as soon as they've been saved to the backend.
+ *
+ * We shouldn't need to worry about [Pruner] removing blobs that get cached here locally,
+ * because we do run [Pruner.removeOldSnapshotsAndPruneUnusedBlobs] only after
+ * a successful backup which is when we also clear cache in [clearLocalCache].
*/
fun saveNewBlob(chunkId: String, blob: Blob) {
val previous = blobMap.put(chunkId, blob)
@@ -140,17 +173,21 @@ class BlobCache(
/**
* Used for populating local [blobMap] cache.
- * Adds mapping from chunkId to [Blob], if it exists on backend, i.e. part of [blobIds]
- * and its size matches the one on backend, i.e. value of [blobIds].
+ * Adds mapping from chunkId to [Blob], if it exists on backend, i.e. part of [allowedBlobIds]
+ * and its size matches the one on backend, i.e. value of [allowedBlobIds].
*/
- private fun onSnapshotLoaded(snapshot: Snapshot, blobIds: Map) {
+ private fun onSnapshotLoaded(snapshot: Snapshot, allowedBlobIds: Map) {
snapshot.blobsMap.forEach { (chunkId, blob) ->
// check if referenced blob still exists on backend
val blobId = blob.id.hexFromProto()
- val sizeOnBackend = blobIds[blobId]
+ val sizeOnBackend = allowedBlobIds[blobId]
if (sizeOnBackend == blob.length) {
// only add blob to our mapping, if it still exists
blobMap.putIfAbsent(chunkId, blob)?.let { previous ->
+ // If there's more than one blob for the same chunk ID, it shouldn't matter
+ // which one we keep on using provided both are still ok.
+ // When we are here, the blob exists on storage and has the same size.
+ // There may still be other corruption such as bit flips in one of the blobs.
if (previous.id != blob.id) log.warn {
"Chunk ID ${chunkId.substring(0..5)} had more than one blob."
}
@@ -165,4 +202,72 @@ class BlobCache(
}
}
+ /**
+ * This is expected to get called by the [Checker] when it finds a blob
+ * that has the expected file size, but its content hash doesn't match what we expect.
+ *
+ * It appends the given [blobId] to our [DO_NOT_USE_FILE_NAME] file.
+ */
+ fun doNotUseBlob(blobId: ByteString) {
+ try {
+ context.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_APPEND).use { outputStream ->
+ val bytes = blobId.toByteArray()
+ check(bytes.size == 32) { "Blob ID $blobId has unexpected size of ${bytes.size}" }
+ outputStream.write(bytes)
+ }
+ } catch (e: Exception) {
+ log.error(e) { "Error adding blob to do-not-use list, may be corrupted: " }
+ }
+ }
+
+ @VisibleForTesting
+ fun getDoNotUseBlobIds(): Set {
+ val blobsIds = mutableSetOf()
+ try {
+ context.openFileInput(DO_NOT_USE_FILE_NAME).use { inputStream ->
+ val bytes = ByteArray(BLOB_ID_SIZE)
+ while (inputStream.read(bytes) == 32) {
+ val blobId = bytes.toHexString()
+ blobsIds.add(blobId)
+ }
+ }
+ } catch (e: FileNotFoundException) {
+ log.info { "No do-not-use list found" }
+ } catch (e: Exception) {
+ log.error(e) { "Our internal do-not-use list is corrupted, deleting it..." }
+ context.deleteFile(DO_NOT_USE_FILE_NAME)
+ }
+ return blobsIds
+ }
+
+ /**
+ * Call this after deleting blobs from the backend,
+ * so we can remove those from our do-not-use list.
+ */
+ fun onBlobsRemoved(blobIds: Set) {
+ log.info { "${blobIds.size} blobs were removed." }
+
+ val blobsIdsToKeep = mutableSetOf()
+
+ try {
+ context.openFileInput(DO_NOT_USE_FILE_NAME).use { inputStream ->
+ val bytes = ByteArray(BLOB_ID_SIZE)
+ while (inputStream.read(bytes) == 32) {
+ val blobId = bytes.toHexString()
+ if (blobId !in blobIds) blobsIdsToKeep.add(blobId)
+ }
+ }
+ } catch (e: FileNotFoundException) {
+ log.info { "No do-not-use list found, no need to remove blobs from it." }
+ return
+ } // if something else goes wrong here, we'll delete the file before next backup
+ context.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_PRIVATE).use { outputStream ->
+ blobsIdsToKeep.forEach { blobId ->
+ val bytes = blobId.toByteArrayFromHex()
+ outputStream.write(bytes)
+ }
+ }
+ log.info { "${blobsIdsToKeep.size} blobs remain on do-not-use list." }
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
new file mode 100644
index 00000000..0fcef513
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
@@ -0,0 +1,237 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault.repo
+
+import androidx.annotation.WorkerThread
+import com.google.protobuf.ByteString
+import com.stevesoltys.seedvault.MemoryLogger
+import com.stevesoltys.seedvault.backend.BackendManager
+import com.stevesoltys.seedvault.crypto.Crypto
+import com.stevesoltys.seedvault.proto.Snapshot
+import com.stevesoltys.seedvault.proto.Snapshot.Blob
+import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
+import io.github.oshai.kotlinlogging.KotlinLogging
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import org.calyxos.seedvault.core.backends.AppBackupFileType
+import org.calyxos.seedvault.core.backends.TopLevelFolder
+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
+
+internal class Checker(
+ private val crypto: Crypto,
+ private val backendManager: BackendManager,
+ private val snapshotManager: SnapshotManager,
+ private val loader: Loader,
+ private val blobCache: BlobCache,
+ private val nm: BackupNotificationManager,
+) {
+ private val log = KotlinLogging.logger { }
+
+ private var handleSize: Int? = null
+ private var snapshots: List? = null
+ private val concurrencyLimit: Int
+ get() {
+ val maxConcurrent = if (backendManager.requiresNetwork) 3 else 42
+ return min(Runtime.getRuntime().availableProcessors(), maxConcurrent)
+ }
+ var checkerResult: CheckerResult? = null
+ private set
+
+ @WorkerThread
+ suspend fun getBackupSize(): Long? {
+ return try {
+ getBackupSizeInt()
+ } catch (e: Exception) {
+ log.error(e) { "Error loading snapshots: " }
+ // we swallow this exception, because an error will be shown in the next step
+ null
+ }
+ }
+
+ private suspend fun getBackupSizeInt(): Long {
+ // get all snapshots
+ val folder = TopLevelFolder(crypto.repoId)
+ val handles = mutableListOf()
+ backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
+ handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
+ }
+ 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() // uses blob.id as key
+ snapshots.forEach { snapshot ->
+ // add sizes to a map first, so we don't double count
+ snapshot.blobsMap.forEach { (_, blob) ->
+ sizeMap[blob.id] = blob.length
+ }
+ }
+ return sizeMap.values.sumOf { it.toLong() }
+ }
+
+ @WorkerThread
+ suspend fun check(percent: Int) {
+ check(percent in 0..100) { "Percent $percent out of bounds." }
+
+ if (snapshots == null) try {
+ getBackupSizeInt() // just get size again to be sure we get snapshots
+ } catch (e: Exception) {
+ nm.onCheckFinishedWithError(0, 0)
+ checkerResult = CheckerResult.GeneralError(e)
+ return
+ }
+ 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.sumOf { it.blob.length.toLong() }
+ log.info { "Blob sample has ${blobSample.size} blobs worth $sampleSize bytes." }
+
+ // check blobs concurrently
+ val semaphore = Semaphore(concurrencyLimit)
+ val size = AtomicLong()
+ val badChunks = ConcurrentSkipListSet()
+ val lastNotification = AtomicLong()
+ val startTime = System.currentTimeMillis()
+ coroutineScope {
+ blobSample.forEach { (chunkId, blob) ->
+ // launch a new co-routine for each blob to check
+ launch {
+ // suspend here until we get a permit from the semaphore (there's free workers)
+ semaphore.withPermit {
+ try {
+ checkBlob(chunkId, blob)
+ } catch (e: HashMismatchException) {
+ log.error(e) { "Error loading chunk $chunkId: " }
+ badChunks.add(ChunkIdBlobPair(chunkId, blob))
+ blobCache.doNotUseBlob(blob.id)
+ } catch (e: Exception) {
+ log.error(e) { "Error loading chunk $chunkId: " }
+ // TODO we could try differentiating transient backend issues
+ badChunks.add(ChunkIdBlobPair(chunkId, blob))
+ }
+ }
+ // keep track of how much we checked and for how long
+ val newSize = size.addAndGet(blob.length.toLong())
+ val passedTime = System.currentTimeMillis() - startTime
+ // only log/show notification after some time has passed (throttling)
+ if (passedTime > lastNotification.get() + 500) {
+ lastNotification.set(passedTime)
+ val bandwidth = (newSize / (passedTime.toDouble() / 1000)).roundToLong()
+ val thousandth = ((newSize.toDouble() / sampleSize) * 1000).roundToInt()
+ log.debug { "$thousandth‰ - $bandwidth KB/sec - $newSize bytes" }
+ nm.showCheckNotification(bandwidth, thousandth)
+ MemoryLogger.log()
+ }
+ }
+ }
+ }
+ if (sampleSize != size.get()) log.error {
+ "Checked ${size.get()} bytes, but expected $sampleSize"
+ }
+ val passedTime = max(System.currentTimeMillis() - startTime, 1000) // no div by zero
+ val bandwidth = size.get() / (passedTime.toDouble() / 1000).roundToLong()
+ 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
+ }
+
+ fun clear() {
+ log.info { "Clearing..." }
+ snapshots = null
+ checkerResult = null
+ }
+
+ private fun getBlobSample(
+ snapshots: List,
+ percent: Int,
+ ): List {
+ // split up blobs for app data and for APKs (use blob.id as key to prevent double counting)
+ val appBlobs = mutableMapOf()
+ val apkBlobs = mutableMapOf()
+ snapshots.forEach { snapshot ->
+ val appChunkIds = snapshot.appsMap.flatMap { it.value.chunkIdsList.hexFromProto() }
+ val apkChunkIds = snapshot.appsMap.flatMap {
+ it.value.apk.splitsList.flatMap { split -> split.chunkIdsList.hexFromProto() }
+ }
+ appChunkIds.forEach { chunkId ->
+ val blob = snapshot.blobsMap[chunkId] ?: error("No Blob for chunkId")
+ appBlobs[blob.id] = ChunkIdBlobPair(chunkId, blob)
+ }
+ apkChunkIds.forEach { chunkId ->
+ val blob = snapshot.blobsMap[chunkId] ?: error("No Blob for chunkId")
+ apkBlobs[blob.id] = ChunkIdBlobPair(chunkId, blob)
+ }
+ }
+ // calculate sizes
+ val appSize = appBlobs.values.sumOf { it.blob.length.toLong() }
+ val apkSize = apkBlobs.values.sumOf { it.blob.length.toLong() }
+ // let's assume it is unlikely that app data and APKs have blobs in common
+ val totalSize = appSize + apkSize
+ log.info { "Got ${appBlobs.size + apkBlobs.size} blobs worth $totalSize bytes to check." }
+
+ // calculate target sizes (how much do we want to check)
+ val targetSize = (totalSize * (percent.toDouble() / 100)).roundToLong()
+ val appTargetSize = min((targetSize * 0.75).roundToLong(), appSize) // 75% of targetSize
+ log.info { "Sampling $targetSize bytes of which $appTargetSize bytes for apps." }
+
+ val blobSample = mutableListOf()
+ var currentSize = 0L
+ // check apps first until we reach their target size
+ val appIterator = appBlobs.values.shuffled().iterator() // random app blob iterator
+ while (currentSize < appTargetSize && appIterator.hasNext()) {
+ val pair = appIterator.next()
+ blobSample.add(pair)
+ currentSize += pair.blob.length
+ }
+ // now check APKs until we reach total targetSize
+ val apkIterator = apkBlobs.values.shuffled().iterator() // random APK blob iterator
+ while (currentSize < targetSize && apkIterator.hasNext()) {
+ val pair = apkIterator.next()
+ blobSample.add(pair)
+ currentSize += pair.blob.length
+ }
+ return blobSample
+ }
+
+ private suspend fun checkBlob(chunkId: String, blob: Blob) {
+ val messageDigest = MessageDigest.getInstance("SHA-256")
+ val storageId = blob.id.hexFromProto()
+ val handle = AppBackupFileType.Blob(crypto.repoId, storageId)
+ val readChunkId = loader.loadFile(handle, null).use { inputStream ->
+ DigestInputStream(inputStream, messageDigest).use { digestStream ->
+ digestStream.readAllBytes()
+ digestStream.messageDigest.digest().toHexString()
+ }
+ }
+ if (readChunkId != chunkId) throw GeneralSecurityException("ChunkId doesn't match")
+ }
+}
+
+data class ChunkIdBlobPair(val chunkId: String, val blob: Blob) : Comparable {
+ override fun compareTo(other: ChunkIdBlobPair): Int {
+ return chunkId.compareTo(other.chunkId)
+ }
+}
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..2be2066d
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/CheckerResult.kt
@@ -0,0 +1,59 @@
+/*
+ * 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(
+ /**
+ * 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,
+ /**
+ * The list of chunkIDs that had errors.
+ */
+ val errorChunkIdBlobPairs: Set,
+ ) : CheckerResult() {
+ val goodSnapshots: List
+ val badSnapshots: List
+
+ init {
+ val good = mutableListOf()
+ val bad = mutableListOf()
+ val errorChunkIds = errorChunkIdBlobPairs.map { it.chunkId }.toSet()
+ snapshots.forEach { snapshot ->
+ val badChunkIds = snapshot.blobsMap.keys.intersect(errorChunkIds)
+ if (badChunkIds.isEmpty()) {
+ // snapshot doesn't contain chunks with erroneous blobs
+ good.add(snapshot)
+ } else {
+ // snapshot may contain chunks with erroneous blobs, check deeper
+ val isBad = badChunkIds.any { chunkId ->
+ val blob = snapshot.blobsMap[chunkId] ?: error("No blob for chunkId")
+ // is this chunkId/blob pair in errorChunkIdBlobPairs?
+ errorChunkIdBlobPairs.any { pair ->
+ pair.chunkId == chunkId && pair.blob == blob
+ }
+ }
+ if (isBad) bad.add(snapshot) else good.add(snapshot)
+ }
+ }
+ goodSnapshots = good
+ badSnapshots = bad
+ }
+ }
+
+ data class GeneralError(val e: Exception) : CheckerResult()
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
index 243ca3b7..3eb1324f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
@@ -83,13 +83,13 @@ internal class Loader(
// check SHA-256 hash first thing
val sha256 = crypto.sha256(cipherText).toHexString()
if (sha256 != expectedHash) {
- throw GeneralSecurityException("File had wrong SHA-256 hash: $expectedHash")
+ throw HashMismatchException("File had wrong SHA-256 hash: $expectedHash")
}
// check that we can handle the version of that snapshot
val version = cipherText[0]
if (version <= 1) throw GeneralSecurityException("Unexpected version: $version")
if (version > VERSION) throw UnsupportedVersionException(version)
- // cache ciperText in cacheFile, if existing
+ // cache cipherText in cacheFile, if existing
try {
cacheFile?.outputStream()?.use { outputStream ->
outputStream.write(cipherText)
@@ -109,3 +109,5 @@ internal class Loader(
}
}
+
+internal class HashMismatchException(msg: String? = null) : GeneralSecurityException(msg)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
index 4284e292..424e173c 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
@@ -23,6 +23,7 @@ internal class Pruner(
private val crypto: Crypto,
private val backendManager: BackendManager,
private val snapshotManager: SnapshotManager,
+ private val blobCache: BlobCache,
) {
private val log = KotlinLogging.logger {}
@@ -77,11 +78,17 @@ internal class Pruner(
blob.id.hexFromProto()
}
}.toSet()
- blobHandles.forEach { blobHandle ->
- if (blobHandle.name !in usedBlobIds) {
- log.info { "Removing blob ${blobHandle.name}" }
- backendManager.backend.remove(blobHandle)
+ val removedBlobs = mutableSetOf()
+ try {
+ blobHandles.forEach { blobHandle ->
+ if (blobHandle.name !in usedBlobIds) {
+ log.info { "Removing blob ${blobHandle.name}" }
+ backendManager.backend.remove(blobHandle)
+ removedBlobs.add(blobHandle.name)
+ }
}
+ } finally {
+ if (removedBlobs.isNotEmpty()) blobCache.onBlobsRemoved(removedBlobs)
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/RepoModule.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/RepoModule.kt
index 8aa72064..948f1105 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/RepoModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/RepoModule.kt
@@ -20,5 +20,6 @@ val repoModule = module {
SnapshotManager(snapshotFolder, get(), get(), get())
}
factory { SnapshotCreatorFactory(androidContext(), get(), get(), get()) }
- factory { Pruner(get(), get(), get()) }
+ factory { Pruner(get(), get(), get(), get()) }
+ single { Checker(get(), get(), get(), get(), get(), get()) }
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt
index d19cbacd..fcf2be16 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt
@@ -13,11 +13,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import com.stevesoltys.seedvault.R
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class AppSelectionFragment : Fragment() {
- private val viewModel: RestoreViewModel by sharedViewModel()
+ private val viewModel: RestoreViewModel by activityViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/FilesSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/FilesSelectionFragment.kt
index 656be0a9..5fd3923b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/FilesSelectionFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/FilesSelectionFragment.kt
@@ -14,11 +14,11 @@ import android.widget.Button
import com.stevesoltys.seedvault.R
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
import org.calyxos.backup.storage.ui.restore.FilesItem
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
internal class FilesSelectionFragment : FileSelectionFragment() {
- override val viewModel: RestoreViewModel by sharedViewModel()
+ override val viewModel: RestoreViewModel by activityViewModel()
private lateinit var button: Button
override fun onCreateView(
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RecycleBackupFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RecycleBackupFragment.kt
index cf054368..8c85f144 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RecycleBackupFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RecycleBackupFragment.kt
@@ -13,11 +13,11 @@ import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class RecycleBackupFragment : Fragment() {
- private val viewModel: RestoreViewModel by sharedViewModel()
+ private val viewModel: RestoreViewModel by activityViewModel()
override fun onCreateView(
inflater: LayoutInflater,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
index e3e9187b..a35cae7f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
@@ -17,10 +17,10 @@ import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R
import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.ui.restore.SnapshotFragment
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
internal class RestoreFilesFragment : SnapshotFragment() {
- override val viewModel: RestoreViewModel by sharedViewModel()
+ override val viewModel: RestoreViewModel by activityViewModel()
override fun onCreateView(
inflater: LayoutInflater,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
index 1f32eb76..32f7b3ff 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
@@ -22,11 +22,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class RestoreProgressFragment : Fragment() {
- private val viewModel: RestoreViewModel by sharedViewModel()
+ private val viewModel: RestoreViewModel by activityViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
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..6f7461db 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
@@ -14,15 +14,17 @@ 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
internal class RestoreSetAdapter(
- private val listener: RestorableBackupClickListener,
+ private val listener: RestorableBackupClickListener?,
private val items: List,
) : Adapter() {
@@ -40,13 +42,21 @@ internal class RestoreSetAdapter(
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
+ private val imageView = v.requireViewById(R.id.imageView)
private val titleView = v.requireViewById(R.id.titleView)
private val appView = v.requireViewById(R.id.appView)
private val apkView = v.requireViewById(R.id.apkView)
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) }
+ }
+ 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) {
@@ -59,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,
@@ -68,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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
index c445125f..f7701302 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
@@ -18,11 +18,11 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class RestoreSetFragment : Fragment() {
- private val viewModel: RestoreViewModel by sharedViewModel()
+ private val viewModel: RestoreViewModel by activityViewModel()
private lateinit var listView: RecyclerView
private lateinit var progressBar: ProgressBar
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
index 014f198c..829c4b0b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
@@ -26,11 +26,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreViewModel
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class InstallProgressFragment : Fragment(), InstallItemListener {
- private val viewModel: RestoreViewModel by sharedViewModel()
+ private val viewModel: RestoreViewModel by activityViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
index dc91cb96..8b8126c8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
@@ -18,7 +18,7 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
internal interface AppStatusToggleListener {
fun onAppStatusToggled(status: AppStatus)
@@ -26,7 +26,7 @@ internal interface AppStatusToggleListener {
class AppStatusFragment : Fragment(), AppStatusToggleListener {
- private val viewModel: SettingsViewModel by sharedViewModel()
+ private val viewModel: SettingsViewModel by activityViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppStatusAdapter(this)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
index 8a3f1de7..879a5acf 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
@@ -20,11 +20,11 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.PackageService
import org.koin.android.ext.android.inject
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class ExpertSettingsFragment : PreferenceFragmentCompat() {
- private val viewModel: SettingsViewModel by sharedViewModel()
+ private val viewModel: SettingsViewModel by activityViewModel()
private val packageService: PackageService by inject()
private val backupManager: IBackupManager by inject()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt
index e30556e6..3ebe7e41 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt
@@ -21,12 +21,12 @@ import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.settings.preference.M3ListPreference
import org.koin.android.ext.android.inject
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class SchedulingFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
- private val viewModel: SettingsViewModel by sharedViewModel()
+ private val viewModel: SettingsViewModel by activityViewModel()
private val settingsManager: SettingsManager by inject()
private val backendManager: BackendManager by inject()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
index c33a47d3..32ed987a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
@@ -31,14 +31,14 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.toRelativeTime
import org.calyxos.seedvault.core.backends.BackendProperties
import org.koin.android.ext.android.inject
-import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
import java.util.concurrent.TimeUnit
private val TAG = SettingsFragment::class.java.name
class SettingsFragment : PreferenceFragmentCompat() {
- private val viewModel: SettingsViewModel by sharedViewModel()
+ private val viewModel: SettingsViewModel by activityViewModel()
private val backendManager: BackendManager by inject()
private val backupStateManager: BackupStateManager by inject()
private val backupManager: IBackupManager by inject()
@@ -49,6 +49,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference
private lateinit var backupScheduling: Preference
+ private lateinit var backupAppCheck: Preference
private lateinit var backupStorage: TwoStatePreference
private lateinit var backupRecoveryCode: Preference
@@ -92,14 +93,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener {
- if (viewModel.isBackupRunning.value) {
- // don't allow changing backup destination while backup is running
- // TODO we could show toast or snackbar here
- false
- } else {
- viewModel.chooseBackupLocation()
- true
- }
+ viewModel.chooseBackupLocation()
+ true
}
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
@@ -117,6 +112,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupStatus = findPreference("backup_status")!!
backupScheduling = findPreference("backup_scheduling")!!
+ backupAppCheck = findPreference("backup_app_check")!!
backupStorage = findPreference("backup_storage")!!
backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
@@ -148,6 +144,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel.backupPossible.observe(viewLifecycleOwner) { possible ->
toolbar.menu.findItem(R.id.action_backup)?.isEnabled = possible
toolbar.menu.findItem(R.id.action_restore)?.isEnabled = possible
+ backupLocation.isEnabled = possible
+ backupAppCheck.isEnabled = possible
}
viewModel.lastBackupTime.observe(viewLifecycleOwner) { time ->
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
index 872804a4..45f5b76b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
@@ -38,12 +38,14 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.permitDiskReads
+import com.stevesoltys.seedvault.repo.Checker
import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.worker.AppBackupWorker
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
+import com.stevesoltys.seedvault.worker.AppCheckerWorker
import com.stevesoltys.seedvault.worker.BackupRequester.Companion.requestFilesAndAppBackup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -69,6 +71,7 @@ internal class SettingsViewModel(
private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup,
private val backupManager: IBackupManager,
+ private val checker: Checker,
backupStateManager: BackupStateManager,
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager) {
@@ -80,10 +83,14 @@ internal class SettingsViewModel(
override val isRestoreOperation = false
val isFirstStart get() = settingsManager.isFirstStart
- val isBackupRunning: StateFlow
+ private val isBackupRunning: StateFlow
+ private val isCheckOrPruneRunning: StateFlow
private val mBackupPossible = MutableLiveData(false)
val backupPossible: LiveData = mBackupPossible
+ private val mBackupSize = MutableLiveData()
+ val backupSize: LiveData = mBackupSize
+
internal val lastBackupTime = settingsManager.lastBackupTime
internal val appBackupWorkInfo =
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
@@ -138,12 +145,23 @@ internal class SettingsViewModel(
started = SharingStarted.Eagerly,
initialValue = false,
)
+ isCheckOrPruneRunning = backupStateManager.isCheckOrPruneRunning.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = false,
+ )
scope.launch {
// update running state
isBackupRunning.collect {
onBackupRunningStateChanged()
}
}
+ scope.launch {
+ // update running state
+ isCheckOrPruneRunning.collect {
+ onBackupRunningStateChanged()
+ }
+ }
onStoragePropertiesChanged()
loadFilesSummary()
}
@@ -166,11 +184,11 @@ internal class SettingsViewModel(
}
private fun onBackupRunningStateChanged() {
- if (isBackupRunning.value) mBackupPossible.postValue(false)
- else viewModelScope.launch(Dispatchers.IO) {
- val canDo = !isBackupRunning.value && !backendManager.isOnUnavailableUsb()
+ val backupAllowed = !isBackupRunning.value && !isCheckOrPruneRunning.value
+ if (backupAllowed) viewModelScope.launch(Dispatchers.IO) {
+ val canDo = !backendManager.isOnUnavailableUsb()
mBackupPossible.postValue(canDo)
- }
+ } else mBackupPossible.postValue(false)
}
private fun onStoragePropertiesChanged() {
@@ -308,6 +326,16 @@ internal class SettingsViewModel(
BackupJobService.cancelJob(app)
}
+ fun loadBackupSize() {
+ viewModelScope.launch(Dispatchers.IO) {
+ mBackupSize.postValue(checker.getBackupSize())
+ }
+ }
+
+ fun checkAppBackups(percent: Int) {
+ AppCheckerWorker.scheduleNow(app, percent)
+ }
+
fun onLogcatUriReceived(uri: Uri?) = viewModelScope.launch(Dispatchers.IO) {
if (uri == null) {
onLogcatError()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
index a5240362..9d80c669 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
@@ -19,6 +19,7 @@ import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.getADForFull
+import com.stevesoltys.seedvault.repo.HashMismatchException
import com.stevesoltys.seedvault.repo.Loader
import libcore.io.IoUtils.closeQuietly
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
@@ -146,6 +147,9 @@ internal class FullRestore(
} catch (e: IOException) {
Log.w(TAG, "Error getting input stream for $packageName", e)
return TRANSPORT_PACKAGE_REJECTED
+ } catch (e: HashMismatchException) {
+ Log.w(TAG, "Hash mismatch for $packageName", e)
+ return TRANSPORT_PACKAGE_REJECTED
} catch (e: SecurityException) {
Log.e(TAG, "Security Exception while getting input stream for $packageName", e)
return TRANSPORT_ERROR
@@ -161,7 +165,7 @@ internal class FullRestore(
return outputFactory.getOutputStream(pfd).use { outputStream ->
try {
copyInputStream(outputStream)
- } catch (e: IOException) {
+ } catch (e: Exception) {
Log.w(TAG, "Error copying stream for package $packageName.", e)
return TRANSPORT_PACKAGE_REJECTED
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
index 2dc7de45..054b75bd 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckFragment.kt
new file mode 100644
index 00000000..f6988b80
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/check/AppCheckFragment.kt
@@ -0,0 +1,89 @@
+/*
+ * 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
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import com.google.android.material.slider.LabelFormatter
+import com.google.android.material.slider.Slider
+import com.stevesoltys.seedvault.R
+import com.stevesoltys.seedvault.settings.SettingsViewModel
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+
+private const val WARN_PERCENT = 25
+private const val WARN_BYTES = 1024 * 1024 * 1024 // 1 GB
+
+class AppCheckFragment : Fragment() {
+
+ private val viewModel: SettingsViewModel by activityViewModel()
+ private lateinit var sliderLabel: TextView
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val v = inflater.inflate(R.layout.fragment_app_check, container, false) as ScrollView
+
+ val slider = v.requireViewById(R.id.slider)
+ sliderLabel = v.requireViewById(R.id.sliderLabel)
+
+ // label not scrolling will be fixed in material-components 1.12.0 (next update)
+ slider.setLabelFormatter { value ->
+ viewModel.backupSize.value?.let {
+ Formatter.formatShortFileSize(context, (it * value / 100).toLong())
+ } ?: "${value.toInt()}%"
+ }
+ slider.addOnChangeListener { _, value, _ ->
+ onSliderChanged(value)
+ }
+
+ viewModel.backupSize.observe(viewLifecycleOwner) {
+ if (it != null) {
+ slider.labelBehavior = LabelFormatter.LABEL_VISIBLE
+ slider.invalidate()
+ onSliderChanged(slider.value)
+ }
+ // we can stop observing as the loaded size won't change again
+ viewModel.backupSize.removeObservers(viewLifecycleOwner)
+ }
+
+ v.requireViewById