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