Merge pull request #785 from grote/check-app-backup

Verify app backup integrity
This commit is contained in:
Torsten Grote 2024-11-19 17:10:28 -03:00 committed by GitHub
commit 8a00a2939a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1795 additions and 95 deletions

View file

@ -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">

View file

@ -90,6 +90,7 @@ open class App : Application() {
storageBackup = get(),
backupManager = get(),
backupStateManager = get(),
checker = get(),
)
}
viewModel {

View file

@ -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

View file

@ -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) {

View file

@ -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." }
}
}

View 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)
}
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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,12 +78,18 @@ internal class Pruner(
blob.id.hexFromProto()
}
}.toSet()
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)
}
}
private fun getTokenToKeep(tokenSet: Set<Long>): Set<Long> {

View file

@ -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()) }
}

View file

@ -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 ->

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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) {
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)
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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,15 +93,9 @@ 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
}
}
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
@ -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 ->

View file

@ -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()

View file

@ -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
}

View file

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

View file

@ -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
}
}
}

View file

@ -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}"
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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.
val chunkIds = oldApk.splitsList.flatMap {
it.chunkIdsList.map { chunkId -> chunkId.hexFromProto() }
}
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."
)
// build up blobMap from old snapshot
val chunkIds = oldApk.splitsList.flatMap {
it.chunkIdsList.map { chunkId -> chunkId.hexFromProto() }
}
// 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")
}
// 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
} else {
Log.w(TAG, "Blobs for APKs of $packageName have issues in backend. Fixing...")
}
}
// builder for Apk object

View file

@ -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) {

View file

@ -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,
)
}

View file

@ -33,6 +33,7 @@ val workerModule = module {
backupReceiver = get(),
appBackupManager = get(),
settingsManager = get(),
blobCache = get(),
)
}
single {

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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">

View file

@ -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)
}

View 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()
}
}

View file

@ -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))
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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"