Merge pull request #785 from grote/check-app-backup
Verify app backup integrity
This commit is contained in:
commit
8a00a2939a
45 changed files with 1795 additions and 95 deletions
|
@ -131,6 +131,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.check.AppCheckResultActivity"
|
||||
android:label="@string/notification_checking_finished_title"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<service
|
||||
android:name=".transport.ConfigurableBackupTransportService"
|
||||
android:exported="false">
|
||||
|
|
|
@ -90,6 +90,7 @@ open class App : Application() {
|
|||
storageBackup = get(),
|
||||
backupManager = get(),
|
||||
backupStateManager = get(),
|
||||
checker = get(),
|
||||
)
|
||||
}
|
||||
viewModel {
|
||||
|
|
|
@ -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<Boolean> = 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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<String>): 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<String, Int>) {
|
||||
private fun onSnapshotLoaded(snapshot: Snapshot, allowedBlobIds: Map<String, Int>) {
|
||||
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<String> {
|
||||
val blobsIds = mutableSetOf<String>()
|
||||
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<String>) {
|
||||
log.info { "${blobIds.size} blobs were removed." }
|
||||
|
||||
val blobsIdsToKeep = mutableSetOf<String>()
|
||||
|
||||
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." }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
237
app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
Normal file
237
app/src/main/java/com/stevesoltys/seedvault/repo/Checker.kt
Normal file
|
@ -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<Snapshot>? = 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<AppBackupFileType.Snapshot>()
|
||||
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<ByteString, Int>() // 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<ChunkIdBlobPair>()
|
||||
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<Snapshot>,
|
||||
percent: Int,
|
||||
): List<ChunkIdBlobPair> {
|
||||
// split up blobs for app data and for APKs (use blob.id as key to prevent double counting)
|
||||
val appBlobs = mutableMapOf<ByteString, ChunkIdBlobPair>()
|
||||
val apkBlobs = mutableMapOf<ByteString, ChunkIdBlobPair>()
|
||||
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<ChunkIdBlobPair>()
|
||||
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<ChunkIdBlobPair> {
|
||||
override fun compareTo(other: ChunkIdBlobPair): Int {
|
||||
return chunkId.compareTo(other.chunkId)
|
||||
}
|
||||
}
|
|
@ -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<Snapshot>,
|
||||
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<Snapshot>,
|
||||
/**
|
||||
* The list of chunkIDs that had errors.
|
||||
*/
|
||||
val errorChunkIdBlobPairs: Set<ChunkIdBlobPair>,
|
||||
) : CheckerResult() {
|
||||
val goodSnapshots: List<Snapshot>
|
||||
val badSnapshots: List<Snapshot>
|
||||
|
||||
init {
|
||||
val good = mutableListOf<Snapshot>()
|
||||
val bad = mutableListOf<Snapshot>()
|
||||
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()
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<RestorableBackup>,
|
||||
) : Adapter<RestoreSetViewHolder>() {
|
||||
|
||||
|
@ -40,13 +42,21 @@ internal class RestoreSetAdapter(
|
|||
|
||||
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
||||
|
||||
private val imageView = v.requireViewById<ImageView>(R.id.imageView)
|
||||
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
||||
private val appView = v.requireViewById<TextView>(R.id.appView)
|
||||
private val apkView = v.requireViewById<TextView>(R.id.apkView)
|
||||
private val timeView = v.requireViewById<TextView>(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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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<Boolean>
|
||||
private val isBackupRunning: StateFlow<Boolean>
|
||||
private val isCheckOrPruneRunning: StateFlow<Boolean>
|
||||
private val mBackupPossible = MutableLiveData(false)
|
||||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||
|
||||
private val mBackupSize = MutableLiveData<Long>()
|
||||
val backupSize: LiveData<Long> = 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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Slider>(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<Button>(R.id.startButton).setOnClickListener {
|
||||
viewModel.checkAppBackups(slider.value.toInt())
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(null)
|
||||
viewModel.loadBackupSize()
|
||||
}
|
||||
|
||||
private fun onSliderChanged(value: Float) {
|
||||
val size = viewModel.backupSize.value
|
||||
// when size is unknown, we show warning based on percent
|
||||
val showWarning = if (size == null) {
|
||||
value > WARN_PERCENT
|
||||
} else {
|
||||
size * value / 100 > WARN_BYTES
|
||||
}
|
||||
// only update label visibility when different from before
|
||||
val newVisibility = if (showWarning) View.VISIBLE else View.GONE
|
||||
if (sliderLabel.visibility != newVisibility) {
|
||||
sliderLabel.visibility = newVisibility
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.ui.check
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.View.GONE
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.repo.Checker
|
||||
import com.stevesoltys.seedvault.repo.CheckerResult
|
||||
import com.stevesoltys.seedvault.restore.RestoreSetAdapter
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.ui.BackupActivity
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
internal const val ACTION_FINISHED = "FINISHED"
|
||||
internal const val ACTION_SHOW = "SHOW"
|
||||
|
||||
class AppCheckResultActivity : BackupActivity() {
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val checker: Checker by inject()
|
||||
private val notificationManager: BackupNotificationManager by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) when (intent.action) {
|
||||
ACTION_FINISHED -> {
|
||||
notificationManager.onCheckCompleteNotificationSeen()
|
||||
checker.clear()
|
||||
finish()
|
||||
}
|
||||
ACTION_SHOW -> {
|
||||
notificationManager.onCheckCompleteNotificationSeen()
|
||||
onActionReceived()
|
||||
}
|
||||
else -> {
|
||||
log.error { "Unknown action: ${intent.action}" }
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onActionReceived() {
|
||||
when (val result = checker.checkerResult) {
|
||||
is CheckerResult.Success -> onSuccess(result)
|
||||
is CheckerResult.Error -> onError(result)
|
||||
is CheckerResult.GeneralError, null -> {
|
||||
if (result == null) {
|
||||
val str = getString(R.string.backup_app_check_error_no_result)
|
||||
val e = NullPointerException(str)
|
||||
val r = CheckerResult.GeneralError(e)
|
||||
onGeneralError(r)
|
||||
} else {
|
||||
onGeneralError(result as CheckerResult.GeneralError)
|
||||
}
|
||||
}
|
||||
}
|
||||
checker.clear()
|
||||
}
|
||||
|
||||
private fun onSuccess(result: CheckerResult.Success) {
|
||||
setContentView(R.layout.activity_check_result)
|
||||
val intro = getString(
|
||||
R.string.backup_app_check_success_intro,
|
||||
result.snapshots.size,
|
||||
result.percent,
|
||||
formatShortFileSize(this, result.size),
|
||||
)
|
||||
requireViewById<TextView>(R.id.introView).text = intro
|
||||
|
||||
val listView = requireViewById<RecyclerView>(R.id.listView)
|
||||
listView.adapter = RestoreSetAdapter(
|
||||
listener = null,
|
||||
items = result.snapshots.map { snapshot ->
|
||||
RestorableBackup("", snapshot)
|
||||
}.sortedByDescending { it.time },
|
||||
)
|
||||
}
|
||||
|
||||
private fun onError(result: CheckerResult.Error) {
|
||||
setContentView(R.layout.activity_check_result)
|
||||
requireViewById<ImageView>(R.id.imageView).setImageResource(R.drawable.ic_cloud_error)
|
||||
requireViewById<TextView>(R.id.titleView).setText(R.string.backup_app_check_error_title)
|
||||
val disclaimerView = requireViewById<TextView>(R.id.disclaimerView)
|
||||
|
||||
val intro = if (result.existingSnapshots == 0) {
|
||||
disclaimerView.visibility = GONE
|
||||
getString(R.string.backup_app_check_error_no_snapshots)
|
||||
} else if (result.snapshots.isEmpty()) {
|
||||
disclaimerView.visibility = GONE
|
||||
getString(
|
||||
R.string.backup_app_check_error_only_broken_snapshots,
|
||||
result.existingSnapshots,
|
||||
)
|
||||
} else {
|
||||
getString(R.string.backup_app_check_error_has_snapshots, result.existingSnapshots)
|
||||
}
|
||||
requireViewById<TextView>(R.id.introView).text = intro
|
||||
|
||||
val items = (result.goodSnapshots.map { snapshot ->
|
||||
RestorableBackup("", snapshot)
|
||||
} + result.badSnapshots.map { snapshot ->
|
||||
RestorableBackup("", snapshot, false)
|
||||
}).sortedByDescending { it.time }
|
||||
val listView = requireViewById<RecyclerView>(R.id.listView)
|
||||
listView.adapter = RestoreSetAdapter(
|
||||
listener = null,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onGeneralError(result: CheckerResult.GeneralError) {
|
||||
setContentView(R.layout.activity_check_result)
|
||||
requireViewById<ImageView>(R.id.imageView).setImageResource(R.drawable.ic_cloud_error)
|
||||
requireViewById<TextView>(R.id.titleView).setText(R.string.backup_app_check_error_title)
|
||||
|
||||
requireViewById<TextView>(R.id.introView).text =
|
||||
getString(R.string.backup_app_check_error_no_snapshots)
|
||||
|
||||
requireViewById<TextView>(R.id.disclaimerView).text =
|
||||
"${result.e.localizedMessage}\n\n${result.e}"
|
||||
}
|
||||
|
||||
}
|
|
@ -6,6 +6,8 @@
|
|||
package com.stevesoltys.seedvault.ui.notification
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityOptions
|
||||
import android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
|
@ -15,10 +17,12 @@ import android.app.NotificationManager.IMPORTANCE_LOW
|
|||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PendingIntent.getActivity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.text.format.Formatter
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat.Action
|
||||
import androidx.core.app.NotificationCompat.Builder
|
||||
|
@ -34,6 +38,9 @@ import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
|||
import com.stevesoltys.seedvault.restore.RestoreActivity
|
||||
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||
import com.stevesoltys.seedvault.ui.check.ACTION_FINISHED
|
||||
import com.stevesoltys.seedvault.ui.check.ACTION_SHOW
|
||||
import com.stevesoltys.seedvault.ui.check.AppCheckResultActivity
|
||||
import kotlin.math.min
|
||||
|
||||
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
|
||||
|
@ -42,6 +49,7 @@ private const val CHANNEL_ID_ERROR = "NotificationError"
|
|||
private const val CHANNEL_ID_RESTORE = "NotificationRestore"
|
||||
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
||||
private const val CHANNEL_ID_PRUNING = "NotificationPruning"
|
||||
private const val CHANNEL_ID_CHECKING = "NotificationChecking"
|
||||
internal const val NOTIFICATION_ID_OBSERVER = 1
|
||||
private const val NOTIFICATION_ID_SUCCESS = 2
|
||||
private const val NOTIFICATION_ID_ERROR = 3
|
||||
|
@ -49,7 +57,9 @@ private const val NOTIFICATION_ID_SPACE_ERROR = 4
|
|||
internal const val NOTIFICATION_ID_RESTORE = 5
|
||||
private const val NOTIFICATION_ID_RESTORE_ERROR = 6
|
||||
internal const val NOTIFICATION_ID_PRUNING = 7
|
||||
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 8
|
||||
internal const val NOTIFICATION_ID_CHECKING = 8
|
||||
internal const val NOTIFICATION_ID_CHECK_FINISHED = 9
|
||||
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 10
|
||||
|
||||
private val TAG = BackupNotificationManager::class.java.simpleName
|
||||
|
||||
|
@ -62,6 +72,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
createNotificationChannel(getRestoreChannel())
|
||||
createNotificationChannel(getRestoreErrorChannel())
|
||||
createNotificationChannel(getPruningChannel())
|
||||
createNotificationChannel(getCheckingChannel())
|
||||
}
|
||||
|
||||
private fun getObserverChannel(): NotificationChannel {
|
||||
|
@ -98,6 +109,11 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
return NotificationChannel(CHANNEL_ID_PRUNING, title, IMPORTANCE_LOW)
|
||||
}
|
||||
|
||||
private fun getCheckingChannel(): NotificationChannel {
|
||||
val title = context.getString(R.string.notification_checking_channel_title)
|
||||
return NotificationChannel(CHANNEL_ID_CHECKING, title, IMPORTANCE_LOW)
|
||||
}
|
||||
|
||||
/**
|
||||
* This should get called for each APK we are backing up.
|
||||
*/
|
||||
|
@ -187,13 +203,13 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}
|
||||
|
||||
fun onBackupSuccess(numBackedUp: Int, total: Int, size: Long) {
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
val sizeStr = formatShortFileSize(context, size)
|
||||
val contentText =
|
||||
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
|
||||
val intent = Intent(context, SettingsActivity::class.java).apply {
|
||||
action = ACTION_APP_STATUS_LIST
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_done)
|
||||
setContentTitle(context.getString(R.string.notification_success_title))
|
||||
|
@ -212,7 +228,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
|
||||
fun onBackupError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_error)
|
||||
setContentTitle(context.getString(R.string.notification_failed_title))
|
||||
|
@ -232,7 +248,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
@SuppressLint("RestrictedApi")
|
||||
fun onFixableBackupError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val actionText = context.getString(R.string.notification_error_action)
|
||||
val action = Action(R.drawable.ic_storage, actionText, pendingIntent)
|
||||
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||
|
@ -267,7 +283,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
|
||||
fun getRestoreNotification() = Notification.Builder(context, CHANNEL_ID_RESTORE).apply {
|
||||
val intent = Intent(context, RestoreActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
setContentIntent(pendingIntent)
|
||||
setSmallIcon(R.drawable.ic_cloud_restore)
|
||||
setContentTitle(context.getString(R.string.notification_restore_title))
|
||||
|
@ -314,7 +330,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}
|
||||
|
||||
fun getPruningNotification(): Notification {
|
||||
return Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
return Builder(context, CHANNEL_ID_PRUNING).apply {
|
||||
setSmallIcon(R.drawable.ic_auto_delete)
|
||||
setContentTitle(context.getString(R.string.notification_pruning_title))
|
||||
setOngoing(true)
|
||||
|
@ -324,10 +340,87 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}.build()
|
||||
}
|
||||
|
||||
fun getCheckNotification() = Builder(context, CHANNEL_ID_CHECKING).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_search)
|
||||
setContentTitle(context.getString(R.string.notification_checking_title))
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||
}
|
||||
|
||||
fun showCheckNotification(speed: Long, thousandth: Int) {
|
||||
val text = "${formatShortFileSize(context, speed)}/s"
|
||||
val notification = getCheckNotification()
|
||||
.setContentText(text)
|
||||
.setProgress(1000, thousandth, false)
|
||||
.build()
|
||||
nm.notify(NOTIFICATION_ID_CHECKING, notification)
|
||||
}
|
||||
|
||||
fun onCheckComplete(size: Long, speed: Long) {
|
||||
val text = context.getString(
|
||||
R.string.notification_checking_finished_text,
|
||||
formatShortFileSize(context, size),
|
||||
"${formatShortFileSize(context, speed)}/s",
|
||||
)
|
||||
val notification = getOnCheckFinishedBuilder()
|
||||
.setContentTitle(context.getString(R.string.notification_checking_finished_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_cloud_done)
|
||||
.build()
|
||||
nm.cancel(NOTIFICATION_ID_CHECKING)
|
||||
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
|
||||
}
|
||||
|
||||
fun onCheckFinishedWithError(size: Long, speed: Long) {
|
||||
val text = context.getString(
|
||||
R.string.notification_checking_error_text,
|
||||
formatShortFileSize(context, size),
|
||||
"${formatShortFileSize(context, speed)}/s",
|
||||
)
|
||||
val notification = getOnCheckFinishedBuilder()
|
||||
.setContentTitle(context.getString(R.string.notification_checking_error_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_cloud_error)
|
||||
.build()
|
||||
nm.cancel(NOTIFICATION_ID_CHECKING)
|
||||
nm.notify(NOTIFICATION_ID_CHECK_FINISHED, notification)
|
||||
}
|
||||
|
||||
private fun getOnCheckFinishedBuilder(): Builder {
|
||||
// the background activity launch (BAL) gets restricted for setDeleteIntent()
|
||||
// if we don't use these special ActivityOptions, may cause issues in future SDKs
|
||||
val options = ActivityOptions.makeBasic()
|
||||
.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
).toBundle()
|
||||
val cIntent = Intent(context, AppCheckResultActivity::class.java).apply {
|
||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
setAction(ACTION_SHOW)
|
||||
}
|
||||
val dIntent = Intent(context, AppCheckResultActivity::class.java).apply {
|
||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
setAction(ACTION_FINISHED)
|
||||
}
|
||||
val contentIntent = getActivity(context, 1, cIntent, FLAG_IMMUTABLE, options)
|
||||
val deleteIntent = getActivity(context, 2, dIntent, FLAG_IMMUTABLE, options)
|
||||
val actionTitle = context.getString(R.string.notification_checking_action)
|
||||
val action = Action.Builder(null, actionTitle, contentIntent).build()
|
||||
return Builder(context, CHANNEL_ID_CHECKING)
|
||||
.setContentIntent(contentIntent)
|
||||
.addAction(action)
|
||||
.setDeleteIntent(deleteIntent)
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
|
||||
fun onCheckCompleteNotificationSeen() {
|
||||
nm.cancel(NOTIFICATION_ID_CHECK_FINISHED)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun onNoMainKeyError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val pendingIntent = getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val actionText = context.getString(R.string.notification_error_action)
|
||||
val action = Action(0, actionText, pendingIntent)
|
||||
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||
|
|
|
@ -39,14 +39,14 @@ import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
|||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.isDebugBuild
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||
import java.util.Locale
|
||||
|
||||
internal const val ARG_FOR_NEW_CODE = "forStoringNewCode"
|
||||
|
||||
class RecoveryCodeInputFragment : Fragment() {
|
||||
|
||||
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
||||
private val viewModel: RecoveryCodeViewModel by activityViewModel()
|
||||
|
||||
private lateinit var introText: TextView
|
||||
private lateinit var doneButton: Button
|
||||
|
|
|
@ -18,11 +18,11 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.isDebugBuild
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||
|
||||
class RecoveryCodeOutputFragment : Fragment() {
|
||||
|
||||
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
||||
private val viewModel: RecoveryCodeViewModel by activityViewModel()
|
||||
|
||||
private lateinit var wordList: RecyclerView
|
||||
private lateinit var confirmCodeButton: Button
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|||
import com.stevesoltys.seedvault.proto.SnapshotKt.split
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import com.stevesoltys.seedvault.repo.BlobCache
|
||||
import com.stevesoltys.seedvault.repo.forProto
|
||||
import com.stevesoltys.seedvault.repo.hexFromProto
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
@ -37,6 +38,7 @@ internal class ApkBackup(
|
|||
private val backupReceiver: BackupReceiver,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val blobCache: BlobCache,
|
||||
) {
|
||||
|
||||
private val snapshotCreator
|
||||
|
@ -101,22 +103,26 @@ internal class ApkBackup(
|
|||
if (!needsBackup && oldApk != null) {
|
||||
// We could also check if there are new feature module splits to back up,
|
||||
// but we rely on the app themselves to re-download those, if needed after restore.
|
||||
Log.d(
|
||||
TAG, "Package $packageName with version $version" +
|
||||
" already has a backup ($backedUpVersion)" +
|
||||
" with the same signature. Not backing it up."
|
||||
)
|
||||
// build up blobMap from old snapshot
|
||||
|
||||
val chunkIds = oldApk.splitsList.flatMap {
|
||||
it.chunkIdsList.map { chunkId -> chunkId.hexFromProto() }
|
||||
}
|
||||
val blobMap = chunkIds.associateWith { chunkId ->
|
||||
latestSnapshot.blobsMap[chunkId] ?: error("Missing blob for $chunkId")
|
||||
if (blobCache.containsAll(chunkIds)) {
|
||||
Log.d(
|
||||
TAG, "Package $packageName with version $version" +
|
||||
" already has a backup ($backedUpVersion)" +
|
||||
" with the same signature. Not backing it up."
|
||||
)
|
||||
// all blobs are cached, i.e. still on backend, so no new backup needed
|
||||
val blobMap = chunkIds.associateWith { chunkId ->
|
||||
latestSnapshot.blobsMap[chunkId] ?: error("Missing blob for $chunkId")
|
||||
}
|
||||
// important: add old APK to snapshot or it wouldn't be part of backup
|
||||
snapshotCreator.onApkBackedUp(packageInfo, oldApk, blobMap)
|
||||
return
|
||||
} else {
|
||||
Log.w(TAG, "Blobs for APKs of $packageName have issues in backend. Fixing...")
|
||||
}
|
||||
// TODO could also check if all blobs are (still) available in BlobCache
|
||||
// important: add old APK to snapshot or it wouldn't be part of backup
|
||||
snapshotCreator.onApkBackedUp(packageInfo, oldApk, blobMap)
|
||||
return
|
||||
}
|
||||
|
||||
// builder for Apk object
|
||||
|
|
|
@ -22,12 +22,14 @@ import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST
|
|||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -100,6 +102,7 @@ class AppBackupWorker(
|
|||
}
|
||||
}
|
||||
|
||||
private val backupStateManager: BackupStateManager by inject()
|
||||
private val backupRequester: BackupRequester by inject()
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val apkBackupManager: ApkBackupManager by inject()
|
||||
|
@ -109,6 +112,10 @@ class AppBackupWorker(
|
|||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i(TAG, "Start worker $this ($id)")
|
||||
if (backupStateManager.isCheckOrPruneRunning.first()) {
|
||||
Log.i(TAG, "isCheckOrPruneRunning was true, so retrying later...")
|
||||
return Result.retry()
|
||||
}
|
||||
try {
|
||||
setForeground(createForegroundInfo())
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy.EXPONENTIAL
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy.REPLACE
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
import com.stevesoltys.seedvault.repo.Checker
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_CHECKING
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.time.Duration
|
||||
|
||||
class AppCheckerWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, workerParams), KoinComponent {
|
||||
|
||||
companion object {
|
||||
private val TAG = AppCheckerWorker::class.simpleName
|
||||
private const val PERCENT = "percent"
|
||||
internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP_CHECK"
|
||||
|
||||
fun scheduleNow(context: Context, percent: Int) {
|
||||
check(percent in 0..100) { "Percent $percent out of bounds." }
|
||||
val data = Data.Builder().putInt(PERCENT, percent).build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<AppCheckerWorker>()
|
||||
.setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.setBackoffCriteria(EXPONENTIAL, Duration.ofSeconds(10))
|
||||
.setInputData(data)
|
||||
.build()
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
Log.i(TAG, "Asking to check $percent% of app backups now...")
|
||||
workManager.enqueueUniqueWork(UNIQUE_WORK_NAME, REPLACE, workRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val backupStateManager: BackupStateManager by inject()
|
||||
private val checker: Checker by inject()
|
||||
private val nm: BackupNotificationManager by inject()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
log.info { "Start worker $this ($id)" }
|
||||
if (backupStateManager.isBackupRunning.first()) {
|
||||
Log.i(TAG, "isBackupRunning was true, so retrying later...")
|
||||
return Result.retry()
|
||||
}
|
||||
try {
|
||||
setForeground(createForegroundInfo())
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error while running setForeground: " }
|
||||
}
|
||||
val percent = inputData.getInt(PERCENT, -1)
|
||||
check(percent in 0..100) { "Percent $percent out of bounds." }
|
||||
|
||||
checker.check(percent)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun createForegroundInfo() = ForegroundInfo(
|
||||
NOTIFICATION_ID_CHECKING,
|
||||
nm.getCheckNotification().build(),
|
||||
FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
|
@ -33,6 +33,7 @@ val workerModule = module {
|
|||
backupReceiver = get(),
|
||||
appBackupManager = get(),
|
||||
settingsManager = get(),
|
||||
blobCache = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
|
|
10
app/src/main/res/drawable/ic_cloud_search.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_search.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M21.86 12.5C21.1 11.63 20.15 11.13 19 11C19 9.05 18.32 7.4 16.96 6.04C15.6 4.68 13.95 4 12 4C10.42 4 9 4.47 7.75 5.43S5.67 7.62 5.25 9.15C4 9.43 2.96 10.08 2.17 11.1S1 13.28 1 14.58C1 16.09 1.54 17.38 2.61 18.43C3.69 19.5 5 20 6.5 20H18.5C19.75 20 20.81 19.56 21.69 18.69C22.56 17.81 23 16.75 23 15.5C23 14.35 22.62 13.35 21.86 12.5M16.57 18L14 15.43C13.43 15.79 12.74 16 12 16C9.79 16 8 14.21 8 12S9.79 8 12 8 16 9.79 16 12C16 12.74 15.79 13.43 15.43 14L18 16.57L16.57 18M14 12C14 13.11 13.11 14 12 14S10 13.11 10 12 10.9 10 12 10 14 10.9 14 12Z" />
|
||||
</vector>
|
78
app/src/main/res/layout/activity_check_result.xml
Normal file
78
app/src/main/res/layout/activity_check_result.xml
Normal file
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
style="@style/SudHeaderIcon"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_cloud_done"
|
||||
app:tint="?android:colorAccent"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
style="@style/SudHeaderTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/notification_checking_finished_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/introView"
|
||||
style="@style/SudDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/backup_app_check_success_intro"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/listView"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="0dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/introView"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:itemCount="4"
|
||||
tools:listitem="@layout/list_item_restore_set" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/disclaimerView"
|
||||
style="@style/SudDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/backup_app_check_success_disclaimer"
|
||||
android:textColor="?colorError"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/listView"
|
||||
app:layout_constraintVertical_bias="0.0" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</ScrollView>
|
104
app/src/main/res/layout/fragment_app_check.xml
Normal file
104
app/src/main/res/layout/fragment_app_check.xml
Normal file
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
style="@style/SudHeaderIcon"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_cloud_search"
|
||||
app:tint="?android:colorAccent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
style="@style/SudHeaderTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_app_check_title"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/descriptionView"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_app_check_text"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/introView"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_app_check_text2"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/descriptionView" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:stepSize="5.0"
|
||||
android:value="10.0"
|
||||
android:valueFrom="5.0"
|
||||
android:valueTo="100.0"
|
||||
app:labelBehavior="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/introView"
|
||||
app:tickVisible="false"
|
||||
tools:labelBehavior="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sliderLabel"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/settings_app_check_warning"
|
||||
android:textColor="?colorError"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/slider"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/startButton"
|
||||
style="@style/SudPrimaryButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="40dp"
|
||||
android:text="@string/settings_app_check_button"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/sliderLabel"
|
||||
app:layout_constraintVertical_bias="1.0" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
|
@ -41,6 +41,8 @@
|
|||
<string name="settings_backup_status_next_backup_past">once conditions are fulfilled</string>
|
||||
<string name="settings_backup_status_next_backup_usb">Backups will happen automatically when you plug in your USB drive</string>
|
||||
<string name="settings_backup_scheduling_title">Backup scheduling</string>
|
||||
<string name="settings_backup_app_check_title">Check integrity</string>
|
||||
<string name="settings_backup_app_check_summary">Ensure that backup is working for restore</string>
|
||||
<string name="settings_backup_exclude_apps">Exclude apps</string>
|
||||
<string name="settings_backup_now">Backup now</string>
|
||||
<string name="settings_category_storage">Storage backup (beta)</string>
|
||||
|
@ -66,6 +68,12 @@
|
|||
<string name="settings_scheduling_metered_title">Back up when using mobile data</string>
|
||||
<string name="settings_scheduling_charging_title">Back up only when charging</string>
|
||||
|
||||
<string name="settings_app_check_title">Verify app backup integrity</string>
|
||||
<string name="settings_app_check_text">Each backup process will automatically check the backup integrity. However, to save time, actual app data will not be downloaded and verified.</string>
|
||||
<string name="settings_app_check_text2">Here you can run a one-time check to verify your app backup. Select how much of the app data should be downloaded for the check. The more you select, the longer it will take, yet the more reliable the check will be. This will run in the background and show a notification once the check is done.</string>
|
||||
<string name="settings_app_check_warning">Warning: Downloading large amounts of data can take a long time.</string>
|
||||
<string name="settings_app_check_button">Check backup</string>
|
||||
|
||||
<string name="settings_expert_title">Expert settings</string>
|
||||
<string name="settings_expert_logcat_title">Save app log</string>
|
||||
<string name="settings_expert_logcat_summary">Developers can diagnose bugs with these logs.\n\nWarning: The log file might contain personally identifiable information. Review before and delete after sharing!</string>
|
||||
|
@ -179,6 +187,22 @@
|
|||
<string name="notification_pruning_channel_title">Removing old backups notification</string>
|
||||
<string name="notification_pruning_title">Removing old backups…</string>
|
||||
|
||||
<string name="notification_checking_channel_title">App backup integrity check</string>
|
||||
<string name="notification_checking_title">Checking app backups…</string>
|
||||
<string name="notification_checking_finished_title">App backup integrity verified</string>
|
||||
<string name="notification_checking_finished_text">Successfully checked %1$s at an average speed of %2$s.</string>
|
||||
<string name="notification_checking_error_title">Backup errors found</string>
|
||||
<string name="notification_checking_error_text">Tap for details. We checked %1$s at an average speed of %2$s.</string>
|
||||
<string name="notification_checking_action">Details</string>
|
||||
|
||||
<string name="backup_app_check_success_intro">%1$d snapshots were found and %2$d%% of their data (%3$s) successfully verified:</string>
|
||||
<string name="backup_app_check_success_disclaimer">Note: we cannot verify whether individual apps include all of their user data in the backup.</string>
|
||||
<string name="backup_app_check_error_title">@string/notification_checking_error_title</string>
|
||||
<string name="backup_app_check_error_no_snapshots">We could not find any backup. Please run a successful backup first and then try checking again.</string>
|
||||
<string name="backup_app_check_error_only_broken_snapshots">We found %1$d backup snapshots. However, all of them were corrupted. Please run a successful backup and then try checking again.</string>
|
||||
<string name="backup_app_check_error_has_snapshots">We found %1$d backup snapshots, some of them are corrupt or have errors. Below are the backups that could be restored.</string>
|
||||
<string name="backup_app_check_error_no_result">We lost the detailed results. Did a long time pass since running the check? Try running again.</string>
|
||||
|
||||
<!-- App Backup and Restore State -->
|
||||
|
||||
<string name="backup_section_system">System data</string>
|
||||
|
@ -211,6 +235,7 @@
|
|||
<string name="restore_restore_set_apps_no_size">Has user data for <xliff:g example="42" id="apps">%1$d</xliff:g> apps</string>
|
||||
<string name="restore_restore_set_apks">Contains <xliff:g example="42" id="apps">%1$d</xliff:g> apps (<xliff:g example="1 GB" id="size">%2$s</xliff:g>)</string>
|
||||
<string name="restore_restore_set_apks_no_size">Contains <xliff:g example="42" id="apps">%1$d</xliff:g> apps</string>
|
||||
<string name="restore_restore_set_can_not_get_restored">This backup has some errors. You may be able to restore it partly.</string>
|
||||
<string name="restore_skip">Don\'t restore</string>
|
||||
<string name="restore_skip_apps">Skip restoring apps</string>
|
||||
<string name="restore_invalid_location_title">No backups found</string>
|
||||
|
|
|
@ -50,6 +50,13 @@
|
|||
app:title="@string/settings_backup_scheduling_title"
|
||||
tools:summary="Next backup: Never" />
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:fragment="com.stevesoltys.seedvault.ui.check.AppCheckFragment"
|
||||
app:icon="@drawable/ic_cloud_search"
|
||||
app:key="backup_app_check"
|
||||
app:summary="@string/settings_backup_app_check_summary"
|
||||
app:title="@string/settings_backup_app_check_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="@string/settings_category_storage">
|
||||
|
|
|
@ -6,15 +6,21 @@
|
|||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_APPEND
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class BlobCacheTest : TransportTest() {
|
||||
|
@ -34,7 +40,10 @@ internal class BlobCacheTest : TransportTest() {
|
|||
assertNull(cache[chunkId2])
|
||||
|
||||
// read saved blobs from cache
|
||||
every { strictContext.openFileInput(any()) } returns file.inputStream()
|
||||
every { strictContext.openFileInput(CACHE_FILE_NAME) } returns file.inputStream()
|
||||
every {
|
||||
strictContext.openFileInput(DO_NOT_USE_FILE_NAME)
|
||||
} throws FileNotFoundException()
|
||||
cache.populateCache(listOf(fileInfo1, fileInfo2), emptyList())
|
||||
|
||||
// now both blobs are in the map
|
||||
|
@ -55,7 +64,10 @@ internal class BlobCacheTest : TransportTest() {
|
|||
|
||||
BlobCache(strictContext).let { cache ->
|
||||
// read saved blobs from cache
|
||||
every { strictContext.openFileInput(any()) } returns file.inputStream()
|
||||
every { strictContext.openFileInput(CACHE_FILE_NAME) } returns file.inputStream()
|
||||
every {
|
||||
strictContext.openFileInput(DO_NOT_USE_FILE_NAME)
|
||||
} throws FileNotFoundException()
|
||||
cache.populateCache(listOf(fileInfo2), emptyList()) // fileInfo1 is missing
|
||||
|
||||
// now only blob2 gets used, because blob1 wasn't on backend
|
||||
|
@ -73,7 +85,10 @@ internal class BlobCacheTest : TransportTest() {
|
|||
|
||||
BlobCache(strictContext).let { cache ->
|
||||
// read saved blobs from cache
|
||||
every { strictContext.openFileInput(any()) } returns file.inputStream()
|
||||
every { strictContext.openFileInput(CACHE_FILE_NAME) } returns file.inputStream()
|
||||
every {
|
||||
strictContext.openFileInput(DO_NOT_USE_FILE_NAME)
|
||||
} throws FileNotFoundException()
|
||||
cache.populateCache(listOf(info, fileInfo2), emptyList()) // info has different size now
|
||||
|
||||
// now only blob2 gets used, because blob1 wasn't on backend
|
||||
|
@ -82,6 +97,32 @@ internal class BlobCacheTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached blob doesn't get used if known bad on do-not-use list`(@TempDir tmpDir: Path) {
|
||||
val file = File(tmpDir.toString(), "tmpCache")
|
||||
val doNotUseFile = File(tmpDir.toString(), "doNotUse")
|
||||
BlobCache(strictContext).apply {
|
||||
saveTwoBlobsToCache(file)
|
||||
every { strictContext.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_APPEND) } answers {
|
||||
FileOutputStream(doNotUseFile, true)
|
||||
}
|
||||
doNotUseBlob(blob1.id)
|
||||
}
|
||||
|
||||
BlobCache(strictContext).let { cache ->
|
||||
// read saved blobs from cache
|
||||
every { strictContext.openFileInput(CACHE_FILE_NAME) } returns file.inputStream()
|
||||
every {
|
||||
strictContext.openFileInput(DO_NOT_USE_FILE_NAME)
|
||||
} answers { doNotUseFile.inputStream() }
|
||||
cache.populateCache(listOf(fileInfo1, fileInfo2), emptyList())
|
||||
|
||||
// now only blob2 gets used, because blob1 is on do-not-use-list
|
||||
assertNull(cache[chunkId1])
|
||||
assertEquals(blob2, cache[chunkId2])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blobs from snapshot get added to cache`() {
|
||||
assertEquals(blob1, snapshot.blobsMap[chunkId1])
|
||||
|
@ -129,8 +170,76 @@ internal class BlobCacheTest : TransportTest() {
|
|||
blobCache.clearLocalCache()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get non-existent do not use list`(@TempDir tmpDir: Path) {
|
||||
val cache = BlobCache(strictContext)
|
||||
|
||||
every { strictContext.openFileInput(DO_NOT_USE_FILE_NAME) } throws FileNotFoundException()
|
||||
|
||||
val blobIds = cache.getDoNotUseBlobIds()
|
||||
assertEquals(emptySet<String>(), blobIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onBlobsRemoved with non-existent do not use list`(@TempDir tmpDir: Path) {
|
||||
val cache = BlobCache(strictContext)
|
||||
|
||||
every { strictContext.openFileInput(DO_NOT_USE_FILE_NAME) } throws FileNotFoundException()
|
||||
|
||||
cache.onBlobsRemoved(setOf("foo", "bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doNotUseBlob persists blobs which get removed later`(@TempDir tmpDir: Path) {
|
||||
val file = File(tmpDir.toString(), "tmpCache")
|
||||
val cache = BlobCache(strictContext)
|
||||
|
||||
// add blobs to list
|
||||
every { strictContext.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_APPEND) } answers {
|
||||
FileOutputStream(file, true)
|
||||
}
|
||||
cache.doNotUseBlob(blob1.id)
|
||||
cache.doNotUseBlob(blob2.id)
|
||||
|
||||
// get blobs from list
|
||||
every { strictContext.openFileInput(DO_NOT_USE_FILE_NAME) } answers {
|
||||
FileInputStream(file)
|
||||
}
|
||||
val blobIds = cache.getDoNotUseBlobIds()
|
||||
assertEquals(setOf(blob1.id.hexFromProto(), blob2.id.hexFromProto()), blobIds)
|
||||
|
||||
// remove first blob from list
|
||||
every { strictContext.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_PRIVATE) } answers {
|
||||
FileOutputStream(file, false)
|
||||
}
|
||||
cache.onBlobsRemoved(setOf(blob1.id.hexFromProto(), "foo", "bar"))
|
||||
|
||||
// getting blobs from list now only returns second blob
|
||||
assertEquals(setOf(blob2.id.hexFromProto()), cache.getDoNotUseBlobIds())
|
||||
|
||||
// remove different blobs leaves empty list
|
||||
cache.onBlobsRemoved(setOf(blob2.id.hexFromProto(), "foo", "bar"))
|
||||
assertEquals(emptySet<String>(), cache.getDoNotUseBlobIds())
|
||||
|
||||
// empty list can be added to still
|
||||
cache.doNotUseBlob(blob1.id)
|
||||
assertEquals(setOf(blob1.id.hexFromProto()), cache.getDoNotUseBlobIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupted do-not-use list gets deleted when getting blobs`(@TempDir tmpDir: Path) {
|
||||
val cache = BlobCache(strictContext)
|
||||
|
||||
// get blobs from list deletes broken file, so we can continue using it
|
||||
every { strictContext.openFileInput(DO_NOT_USE_FILE_NAME) } throws IOException()
|
||||
every { strictContext.deleteFile(DO_NOT_USE_FILE_NAME) } returns true
|
||||
cache.getDoNotUseBlobIds()
|
||||
|
||||
verify { strictContext.deleteFile(DO_NOT_USE_FILE_NAME) }
|
||||
}
|
||||
|
||||
private fun BlobCache.saveTwoBlobsToCache(file: File) {
|
||||
every { strictContext.openFileOutput(any(), any()) } answers {
|
||||
every { strictContext.openFileOutput(CACHE_FILE_NAME, MODE_APPEND) } answers {
|
||||
FileOutputStream(file, true)
|
||||
}
|
||||
|
||||
|
|
388
app/src/test/java/com/stevesoltys/seedvault/repo/CheckerTest.kt
Normal file
388
app/src/test/java/com/stevesoltys/seedvault/repo/CheckerTest.kt
Normal file
|
@ -0,0 +1,388 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
|
||||
import com.stevesoltys.seedvault.proto.copy
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.FileInfo
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
internal class CheckerTest : TransportTest() {
|
||||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val snapshotManager: SnapshotManager = mockk()
|
||||
private val loader: Loader = mockk()
|
||||
private val blobCache: BlobCache = mockk()
|
||||
private val nm: BackupNotificationManager = mockk()
|
||||
private val backend: Backend = mockk()
|
||||
|
||||
private val checker = Checker(crypto, backendManager, snapshotManager, loader, blobCache, nm)
|
||||
private val folder = TopLevelFolder(repoId)
|
||||
|
||||
private val snapshotHandle1 =
|
||||
AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
|
||||
private val snapshotHandle2 =
|
||||
AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
|
||||
|
||||
@Test
|
||||
fun `getBackupSize returns 0 for no data`() = runBlocking {
|
||||
expectLoadingSnapshots(emptyMap())
|
||||
|
||||
assertEquals(0, checker.getBackupSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBackupSize returns null on error`() = runBlocking {
|
||||
every { crypto.repoId } returns repoId
|
||||
every { backendManager.backend } returns backend
|
||||
coEvery {
|
||||
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
|
||||
} throws IOException()
|
||||
|
||||
assertNull(checker.getBackupSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBackupSize returns size without double counting blobs`() = runBlocking {
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy { token = 1 },
|
||||
snapshotHandle2 to snapshot.copy { token = 2 },
|
||||
)
|
||||
val expectedSize = blob1.length.toLong() + blob2.length.toLong()
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
|
||||
assertEquals(expectedSize, checker.getBackupSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBackupSize returns size without under-counting blobs with same chunkId`() =
|
||||
runBlocking {
|
||||
val apk = apk.copy {
|
||||
splits.clear()
|
||||
splits.add(baseSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId1))
|
||||
})
|
||||
}
|
||||
val snapshot = snapshot.copy {
|
||||
apps[packageName] = app.copy { this.apk = apk }
|
||||
blobs.clear()
|
||||
}
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy {
|
||||
token = 1
|
||||
blobs[chunkId1] = blob1
|
||||
},
|
||||
snapshotHandle2 to snapshot.copy {
|
||||
token = 2
|
||||
blobs[chunkId1] = blob2
|
||||
},
|
||||
)
|
||||
val expectedSize = blob1.length.toLong() + blob2.length.toLong()
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
|
||||
assertEquals(expectedSize, checker.getBackupSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check works even with no backup data`() = runBlocking {
|
||||
expectLoadingSnapshots(emptyMap())
|
||||
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
every { nm.onCheckFinishedWithError(0, 0) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Error
|
||||
assertEquals(emptyList<Snapshot>(), result.snapshots)
|
||||
assertEquals(0, result.existingSnapshots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check returns error when loading snapshots fails`() = runBlocking {
|
||||
every { crypto.repoId } returns repoId
|
||||
every { backendManager.backend } returns backend
|
||||
coEvery {
|
||||
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
|
||||
} throws IOException("foo")
|
||||
every { nm.onCheckFinishedWithError(0, 0) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
// assert the right exception gets passed on in error result
|
||||
assertInstanceOf(CheckerResult.GeneralError::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.GeneralError
|
||||
assertInstanceOf(IOException::class.java, result.e)
|
||||
assertEquals("foo", result.e.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check raises error for wrong chunkIDs`() = runBlocking {
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy { token = 1 },
|
||||
snapshotHandle2 to snapshot.copy { token = 2 },
|
||||
)
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
|
||||
val data = ByteArray(0)
|
||||
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data)
|
||||
coEvery { loader.loadFile(blobHandle2, null) } returns ByteArrayInputStream(data)
|
||||
|
||||
every { nm.onCheckFinishedWithError(any(), any()) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Error
|
||||
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
|
||||
assertEquals(snapshotMap.values.toSet(), result.badSnapshots.toSet())
|
||||
assertEquals(emptyList<Snapshot>(), result.goodSnapshots)
|
||||
assertEquals(snapshotMap.size, result.existingSnapshots)
|
||||
val errorPairs = setOf(ChunkIdBlobPair(chunkId1, blob1), ChunkIdBlobPair(chunkId2, blob2))
|
||||
assertEquals(errorPairs, result.errorChunkIdBlobPairs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check records hash error from loader`() = runBlocking {
|
||||
// chunkId is "real"
|
||||
val data1 = getRandomByteArray()
|
||||
val chunkId1 = MessageDigest.getInstance("SHA-256").digest(data1).toHexString()
|
||||
// each snapshot gets a different blob
|
||||
val apk1 = apk.copy {
|
||||
splits.clear()
|
||||
splits.add(baseSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId1))
|
||||
})
|
||||
}
|
||||
val apk2 = apk.copy {
|
||||
splits.clear()
|
||||
splits.add(baseSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId2))
|
||||
})
|
||||
}
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy {
|
||||
token = 1
|
||||
apps[packageName] = app.copy { this.apk = apk1 }
|
||||
blobs.clear()
|
||||
blobs[chunkId1] = blob1
|
||||
},
|
||||
snapshotHandle2 to snapshot.copy {
|
||||
token = 2
|
||||
apps[packageName] = app.copy { this.apk = apk2 }
|
||||
},
|
||||
)
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
|
||||
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data1)
|
||||
coEvery { loader.loadFile(blobHandle2, null) } throws HashMismatchException()
|
||||
|
||||
every { blobCache.doNotUseBlob(ByteString.fromHex(blobHandle2.name)) } just Runs
|
||||
every { nm.onCheckFinishedWithError(any(), any()) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Error
|
||||
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
|
||||
assertEquals(listOf(snapshotMap[snapshotHandle1]), result.goodSnapshots)
|
||||
assertEquals(listOf(snapshotMap[snapshotHandle2]), result.badSnapshots)
|
||||
assertEquals(snapshotMap.size, result.existingSnapshots)
|
||||
val errorPairs = setOf(ChunkIdBlobPair(chunkId2, blob2))
|
||||
assertEquals(errorPairs, result.errorChunkIdBlobPairs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check with 100 percent works`() = runBlocking {
|
||||
// get "real" data for blobs
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
val data1 = getRandomByteArray()
|
||||
val data2 = getRandomByteArray()
|
||||
val chunkId1 = messageDigest.digest(data1).toHexString()
|
||||
val chunkId2 = messageDigest.digest(data2).toHexString()
|
||||
val apk = apk.copy {
|
||||
splits.clear()
|
||||
splits.add(baseSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId1))
|
||||
})
|
||||
splits.add(apkSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId2))
|
||||
})
|
||||
}
|
||||
val snapshot = snapshot.copy {
|
||||
apps[packageName] = app.copy { this.apk = apk }
|
||||
blobs[chunkId1] = blob1
|
||||
blobs[chunkId2] = blob2
|
||||
}
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy { token = 1 },
|
||||
snapshotHandle2 to snapshot.copy { token = 2 },
|
||||
)
|
||||
val expectedSize = blob1.length.toLong() + blob2.length.toLong()
|
||||
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
|
||||
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data1)
|
||||
coEvery { loader.loadFile(blobHandle2, null) } returns ByteArrayInputStream(data2)
|
||||
|
||||
every { nm.onCheckComplete(expectedSize, any()) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
assertInstanceOf(CheckerResult.Success::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Success
|
||||
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
|
||||
assertEquals(100, result.percent)
|
||||
assertEquals(expectedSize, result.size)
|
||||
|
||||
verify {
|
||||
nm.onCheckComplete(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check prefers app data over APKs`() = runBlocking {
|
||||
val appDataBlob = blob {
|
||||
id = ByteString.copyFrom(Random.nextBytes(32))
|
||||
length = Random.nextInt(1, Int.MAX_VALUE)
|
||||
uncompressedLength = Random.nextInt(1, Int.MAX_VALUE)
|
||||
}
|
||||
val appDataBlobHandle1 = AppBackupFileType.Blob(repoId, appDataBlob.id.hexFromProto())
|
||||
val appDataChunkId = Random.nextBytes(32).toHexString()
|
||||
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy {
|
||||
token = 1
|
||||
apps[packageName] = app.copy { chunkIds.add(ByteString.fromHex(appDataChunkId)) }
|
||||
blobs[appDataChunkId] = appDataBlob
|
||||
},
|
||||
)
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
|
||||
// only loading app data, not other blobs
|
||||
coEvery { loader.loadFile(appDataBlobHandle1, null) } throws SecurityException()
|
||||
|
||||
println("appDataBlob.length = $appDataBlob.length")
|
||||
every { nm.onCheckFinishedWithError(appDataBlob.length.toLong(), any()) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(1) // 1% to minimize chance of selecting a non-app random blob
|
||||
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Error
|
||||
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
|
||||
assertEquals(snapshotMap.values.toSet(), result.badSnapshots.toSet())
|
||||
assertEquals(snapshotMap.size, result.existingSnapshots)
|
||||
val errorPairs = setOf(ChunkIdBlobPair(appDataChunkId, appDataBlob))
|
||||
assertEquals(errorPairs, result.errorChunkIdBlobPairs)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
loader.loadFile(blobHandle1, null)
|
||||
loader.loadFile(blobHandle2, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check doesn't skip broken blobs that have a fix with same chunkID`() = runBlocking {
|
||||
// get "real" data for blob2
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
val data1 = getRandomByteArray() // broken blob
|
||||
val data2 = getRandomByteArray() // data2 matches chunkId
|
||||
val chunkId = messageDigest.digest(data2).toHexString()
|
||||
val apk = apk.copy {
|
||||
splits.clear()
|
||||
splits.add(baseSplit.copy {
|
||||
this.chunkIds.clear()
|
||||
chunkIds.add(ByteString.fromHex(chunkId))
|
||||
})
|
||||
}
|
||||
val snapshot = snapshot.copy {
|
||||
apps[packageName] = app.copy { this.apk = apk }
|
||||
blobs.clear()
|
||||
}
|
||||
val snapshotMap = mapOf(
|
||||
snapshotHandle1 to snapshot.copy {
|
||||
token = 1
|
||||
blobs[chunkId] = blob1 // snapshot1 has broken blob for chunkId
|
||||
},
|
||||
snapshotHandle2 to snapshot.copy {
|
||||
token = 2
|
||||
blobs[chunkId] = blob2 // snapshot2 has fixed blob for chunkId
|
||||
},
|
||||
)
|
||||
|
||||
expectLoadingSnapshots(snapshotMap)
|
||||
every { backendManager.requiresNetwork } returns Random.nextBoolean()
|
||||
|
||||
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data1)
|
||||
coEvery { loader.loadFile(blobHandle2, null) } returns ByteArrayInputStream(data2)
|
||||
|
||||
every { nm.onCheckFinishedWithError(any(), any()) } just Runs
|
||||
|
||||
assertNull(checker.checkerResult)
|
||||
checker.check(100)
|
||||
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
|
||||
val result = checker.checkerResult as CheckerResult.Error
|
||||
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
|
||||
assertEquals(setOf(snapshotMap[snapshotHandle2]), result.goodSnapshots.toSet())
|
||||
assertEquals(setOf(snapshotMap[snapshotHandle1]), result.badSnapshots.toSet())
|
||||
assertEquals(snapshotMap.size, result.existingSnapshots)
|
||||
val errorPairs = setOf(ChunkIdBlobPair(chunkId, blob1))
|
||||
assertEquals(errorPairs, result.errorChunkIdBlobPairs)
|
||||
}
|
||||
|
||||
private suspend fun expectLoadingSnapshots(
|
||||
snapshots: Map<AppBackupFileType.Snapshot, Snapshot>,
|
||||
) {
|
||||
every { crypto.repoId } returns repoId
|
||||
every { backendManager.backend } returns backend
|
||||
coEvery {
|
||||
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
|
||||
} answers {
|
||||
snapshots.keys.forEach {
|
||||
val fileInfo = FileInfo(it, Random.nextLong(Long.MAX_VALUE))
|
||||
lambda<(FileInfo) -> Unit>().captured.invoke(fileInfo)
|
||||
}
|
||||
}
|
||||
coEvery {
|
||||
snapshotManager.onSnapshotsLoaded(snapshots.keys.toList())
|
||||
} returns snapshots.values.toList()
|
||||
}
|
||||
|
||||
}
|
|
@ -33,9 +33,10 @@ internal class PrunerTest : TransportTest() {
|
|||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val snapshotManager: SnapshotManager = mockk()
|
||||
private val blobCache: BlobCache = mockk()
|
||||
private val backend: Backend = mockk()
|
||||
|
||||
private val pruner = Pruner(crypto, backendManager, snapshotManager)
|
||||
private val pruner = Pruner(crypto, backendManager, snapshotManager, blobCache)
|
||||
private val folder = TopLevelFolder(repoId)
|
||||
|
||||
private val snapshotHandle1 =
|
||||
|
@ -84,6 +85,7 @@ internal class PrunerTest : TransportTest() {
|
|||
blobIds.forEach {
|
||||
coEvery { backend.remove(AppBackupFileType.Blob(repoId, it)) } just Runs
|
||||
}
|
||||
every { blobCache.onBlobsRemoved(blobIds.toSet()) } just Runs
|
||||
|
||||
pruner.removeOldSnapshotsAndPruneUnusedBlobs()
|
||||
}
|
||||
|
@ -143,12 +145,14 @@ internal class PrunerTest : TransportTest() {
|
|||
// now extra blobs will get removed
|
||||
coEvery { backend.remove(AppBackupFileType.Blob(repoId, blob1)) } just Runs
|
||||
coEvery { backend.remove(AppBackupFileType.Blob(repoId, blob2)) } just Runs
|
||||
every { blobCache.onBlobsRemoved(setOf(blob1, blob2)) } just Runs
|
||||
|
||||
pruner.removeOldSnapshotsAndPruneUnusedBlobs()
|
||||
|
||||
coVerify {
|
||||
backend.remove(AppBackupFileType.Blob(repoId, blob1))
|
||||
backend.remove(AppBackupFileType.Blob(repoId, blob2))
|
||||
blobCache.onBlobsRemoved(setOf(blob1, blob2))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
|||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import com.stevesoltys.seedvault.repo.BlobCache
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import com.stevesoltys.seedvault.repo.SnapshotCreator
|
||||
import com.stevesoltys.seedvault.repo.SnapshotManager
|
||||
|
@ -70,6 +71,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
private val backupStateManager: BackupStateManager = mockk()
|
||||
private val backupReceiver: BackupReceiver = mockk()
|
||||
private val appBackupManager: AppBackupManager = mockk()
|
||||
private val blobCache: BlobCache = mockk()
|
||||
private val snapshotManager: SnapshotManager = mockk()
|
||||
private val snapshotCreator: SnapshotCreator = mockk()
|
||||
private val loader: Loader = mockk()
|
||||
|
@ -81,7 +83,8 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
private val apkInstaller: ApkInstaller = mockk()
|
||||
private val installRestriction: InstallRestriction = mockk()
|
||||
|
||||
private val apkBackup = ApkBackup(pm, backupReceiver, appBackupManager, settingsManager)
|
||||
private val apkBackup =
|
||||
ApkBackup(pm, backupReceiver, appBackupManager, settingsManager, blobCache)
|
||||
private val apkRestore: ApkRestore = ApkRestore(
|
||||
context = strictContext,
|
||||
backupManager = backupManager,
|
||||
|
|
|
@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.coAssertThrows
|
|||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.repo.HashMismatchException
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
|
@ -32,7 +33,9 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.SequenceInputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import kotlin.random.Random
|
||||
|
||||
|
@ -114,6 +117,36 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
verify { fileDescriptor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading from stream throws HashMismatchException in SequenceInputStream`() = runBlocking {
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
val bytes = getRandomByteArray()
|
||||
|
||||
val inputStream = SequenceInputStream(
|
||||
ByteArrayInputStream(bytes),
|
||||
object : FilterInputStream(ByteArrayInputStream(bytes)) {
|
||||
override fun read(): Int {
|
||||
throw HashMismatchException()
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
throw HashMismatchException()
|
||||
}
|
||||
},
|
||||
)
|
||||
coEvery { loader.loadFiles(blobHandles) } returns inputStream
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(bytes.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor),
|
||||
)
|
||||
|
||||
verify { fileDescriptor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full chunk gets decrypted`() = runBlocking {
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
|
|
|
@ -23,7 +23,9 @@ import com.stevesoltys.seedvault.proto.copy
|
|||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.BackupData
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import com.stevesoltys.seedvault.repo.BlobCache
|
||||
import com.stevesoltys.seedvault.repo.SnapshotCreator
|
||||
import com.stevesoltys.seedvault.repo.hexFromProto
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
|
@ -51,9 +53,11 @@ internal class ApkBackupTest : BackupTest() {
|
|||
private val pm: PackageManager = mockk()
|
||||
private val backupReceiver: BackupReceiver = mockk()
|
||||
private val appBackupManager: AppBackupManager = mockk()
|
||||
private val blobCache: BlobCache = mockk()
|
||||
private val snapshotCreator: SnapshotCreator = mockk()
|
||||
|
||||
private val apkBackup = ApkBackup(pm, backupReceiver, appBackupManager, settingsManager)
|
||||
private val apkBackup =
|
||||
ApkBackup(pm, backupReceiver, appBackupManager, settingsManager, blobCache)
|
||||
|
||||
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
|
||||
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
|
||||
|
@ -110,7 +114,9 @@ internal class ApkBackupTest : BackupTest() {
|
|||
val apk = apk.copy { versionCode = packageInfo.longVersionCode }
|
||||
val app = app { this.apk = apk }
|
||||
val s = snapshot.copy { apps.put(packageName, app) }
|
||||
val chunkIds = apk.splitsList.flatMap { it.chunkIdsList.hexFromProto() }
|
||||
expectChecks()
|
||||
every { blobCache.containsAll(chunkIds) } returns true
|
||||
every {
|
||||
snapshotCreator.onApkBackedUp(packageInfo, apk, blobMap)
|
||||
} just Runs
|
||||
|
@ -162,6 +168,47 @@ internal class ApkBackupTest : BackupTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does back up the same version when blobs are missing from cache`(@TempDir tmpDir: Path) =
|
||||
runBlocking {
|
||||
val tmpFile = File(tmpDir.toAbsolutePath().toString())
|
||||
packageInfo.applicationInfo!!.sourceDir = File(tmpFile, "test.apk").apply {
|
||||
assertTrue(createNewFile())
|
||||
}.absolutePath
|
||||
val apk = apk.copy {
|
||||
versionCode = packageInfo.longVersionCode
|
||||
splits.clear()
|
||||
splits.add(baseSplit)
|
||||
}
|
||||
val app = app { this.apk = apk }
|
||||
val s = snapshot.copy { apps.put(packageName, app) }
|
||||
val chunkIds = apk.splitsList.flatMap { it.chunkIdsList.hexFromProto() }
|
||||
expectChecks()
|
||||
every { blobCache.containsAll(chunkIds) } returns false // blobs missing here
|
||||
|
||||
every {
|
||||
pm.getInstallSourceInfo(packageInfo.packageName)
|
||||
} returns InstallSourceInfo(null, null, null, apk.installer)
|
||||
coEvery {
|
||||
backupReceiver.readFromStream("APK backup $packageName ", any())
|
||||
} returns apkBackupData
|
||||
|
||||
every {
|
||||
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
|
||||
it.installer == apk.installer
|
||||
}, apkBackupData.blobMap)
|
||||
} just Runs
|
||||
|
||||
apkBackup.backupApkIfNecessary(packageInfo, s)
|
||||
|
||||
coVerify {
|
||||
backupReceiver.readFromStream("APK backup $packageName ", any())
|
||||
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
|
||||
it.installer == apk.installer
|
||||
}, apkBackupData.blobMap)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws exception when APK doesn't exist`() {
|
||||
packageInfo.applicationInfo!!.sourceDir = "/tmp/doesNotExist"
|
||||
|
|
Loading…
Reference in a new issue