Skip to content

Commit

Permalink
Merge pull request seedvault-app#819 from grote/check-files-backup
Browse files Browse the repository at this point in the history
Integrity verification of files backup
  • Loading branch information
grote authored Dec 18, 2024
2 parents 18d2135 + 9474a8f commit 156ffbd
Show file tree
Hide file tree
Showing 75 changed files with 2,249 additions and 308 deletions.
3 changes: 2 additions & 1 deletion .idea/dictionaries/user.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@
android:label="@string/notification_checking_finished_title"
android:launchMode="singleTask"/>

<activity
android:name=".ui.check.FileCheckResultActivity"
android:launchMode="singleTask"/>

<service
android:name=".transport.ConfigurableBackupTransportService"
android:exported="false">
Expand Down
16 changes: 10 additions & 6 deletions app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 com.stevesoltys.seedvault.worker.FileCheckerWorker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine

Expand All @@ -36,7 +37,7 @@ class BackupStateManager(
) { appBackupRunning, filesBackupRunning, workInfo1 ->
val workInfoState1 = workInfo1.getOrNull(0)?.state
Log.i(
TAG, "appBackupRunning: $appBackupRunning, " +
TAG, "B - appBackupRunning: $appBackupRunning, " +
"filesBackupRunning: $filesBackupRunning, " +
"appBackupWorker: ${workInfoState1?.name}"
)
Expand All @@ -46,15 +47,18 @@ class BackupStateManager(
val isCheckOrPruneRunning: Flow<Boolean> = combine(
flow = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
flow2 = workManager.getWorkInfosForUniqueWorkFlow(AppCheckerWorker.UNIQUE_WORK_NAME),
) { pruneInfo, checkInfo ->
flow3 = workManager.getWorkInfosForUniqueWorkFlow(FileCheckerWorker.UNIQUE_WORK_NAME),
) { pruneInfo, appCheckInfo, fileCheckInfo ->
val pruneInfoState = pruneInfo.getOrNull(0)?.state
val checkInfoState = checkInfo.getOrNull(0)?.state
val appCheckState = appCheckInfo.getOrNull(0)?.state
val fileCheckState = fileCheckInfo.getOrNull(0)?.state
Log.i(
TAG,
"pruneBackupWorker: ${pruneInfoState?.name}, " +
"appCheckerWorker: ${checkInfoState?.name}"
"C - pruneBackupWorker: ${pruneInfoState?.name}, " +
"appCheckerWorker: ${appCheckState?.name}, " +
"fileCheckerWorker: ${fileCheckState?.name}"
)
pruneInfoState == RUNNING || checkInfoState == RUNNING
pruneInfoState == RUNNING || appCheckState == RUNNING || fileCheckState == RUNNING
}

val isAutoRestoreEnabled: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,23 @@ import com.stevesoltys.seedvault.settings.StoragePluginType
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.BackendFactory
import org.calyxos.seedvault.core.backends.BackendProperties
import org.calyxos.seedvault.core.backends.IBackendManager
import org.calyxos.seedvault.core.backends.saf.SafBackend

class BackendManager(
private val context: Context,
private val settingsManager: SettingsManager,
private val blobCache: BlobCache,
backendFactory: BackendFactory,
) {
) : IBackendManager {

@Volatile
private var mBackend: Backend?

@Volatile
private var mBackendProperties: BackendProperties<*>?

val backend: Backend
override val backend: Backend
@Synchronized
get() {
return mBackend ?: error("App plugin was loaded, but still null")
Expand All @@ -42,8 +43,8 @@ class BackendManager(
get() {
return mBackendProperties
}
val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true
val requiresNetwork: Boolean get() = backendProperties?.requiresNetwork == true
override val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true
override val requiresNetwork: Boolean get() = backendProperties?.requiresNetwork == true

init {
when (settingsManager.storagePluginType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.settings.BackupPermission.BackupAllowed
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.toRelativeTime
import org.calyxos.seedvault.core.backends.BackendProperties
Expand All @@ -51,6 +52,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backupScheduling: Preference
private lateinit var backupAppCheck: Preference
private lateinit var backupStorage: TwoStatePreference
private lateinit var backupFileCheck: Preference
private lateinit var backupRecoveryCode: Preference

private val backendProperties: BackendProperties<*>?
Expand Down Expand Up @@ -82,8 +84,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
trySetBackupEnabled(false)
dialog.dismiss()
}
.setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { dialog,
_ -> dialog.dismiss()
.setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { d, _ ->
d.dismiss()
}
.show()
return@OnPreferenceChangeListener false
Expand Down Expand Up @@ -125,6 +127,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
onEnablingStorageBackup()
return@OnPreferenceChangeListener false
}
backupFileCheck = findPreference("backup_file_check")!!

backupRecoveryCode = findPreference("backup_recovery_code")!!
}
Expand All @@ -141,11 +144,16 @@ 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.backupPossible.observe(viewLifecycleOwner) { permission ->
val allowed = permission == BackupAllowed
toolbar.menu.findItem(R.id.action_backup)?.isEnabled = allowed
toolbar.menu.findItem(R.id.action_restore)?.isEnabled = allowed
// backup location can be changed when backup isn't allowed,
// because flash-drive isn't plugged in
backupLocation.isEnabled = allowed ||
(permission as? BackupPermission.BackupRestricted)?.unavailableUsb == true
backupAppCheck.isEnabled = allowed
backupFileCheck.isEnabled = allowed
}

viewModel.lastBackupTime.observe(viewLifecycleOwner) { time ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ 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.settings.BackupPermission.BackupAllowed
import com.stevesoltys.seedvault.settings.BackupPermission.BackupRestricted
import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
Expand All @@ -47,6 +49,7 @@ 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 com.stevesoltys.seedvault.worker.FileCheckerWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -63,6 +66,11 @@ import java.util.concurrent.TimeUnit.HOURS
private const val TAG = "SettingsViewModel"
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"

sealed class BackupPermission {
object BackupAllowed : BackupPermission()
class BackupRestricted(val unavailableUsb: Boolean = false) : BackupPermission()
}

internal class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
Expand All @@ -85,11 +93,13 @@ internal class SettingsViewModel(

private val isBackupRunning: StateFlow<Boolean>
private val isCheckOrPruneRunning: StateFlow<Boolean>
private val mBackupPossible = MutableLiveData(false)
val backupPossible: LiveData<Boolean> = mBackupPossible
private val mBackupPossible = MutableLiveData<BackupPermission>(BackupRestricted())
val backupPossible: LiveData<BackupPermission> = mBackupPossible

private val mBackupSize = MutableLiveData<Long>()
val backupSize: LiveData<Long> = mBackupSize
private val mFilesBackupSize = MutableLiveData<Long>()
val filesBackupSize: LiveData<Long> = mFilesBackupSize

internal val lastBackupTime = settingsManager.lastBackupTime
internal val appBackupWorkInfo =
Expand All @@ -99,9 +109,6 @@ internal class SettingsViewModel(

private val mAppStatusList = lastBackupTime.switchMap {
// updates app list when lastBackupTime changes
// FIXME: Since we are currently updating that time a lot,
// re-fetching everything on each change hammers the system hard
// which can cause android.os.DeadObjectException
getAppStatusResult()
}
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
Expand Down Expand Up @@ -183,12 +190,24 @@ internal class SettingsViewModel(
onStoragePropertiesChanged()
}

private fun onBackupRunningStateChanged() {
private suspend fun onBackupRunningStateChanged() = withContext(Dispatchers.IO) {
val backupAllowed = !isBackupRunning.value && !isCheckOrPruneRunning.value
if (backupAllowed) viewModelScope.launch(Dispatchers.IO) {
val canDo = !backendManager.isOnUnavailableUsb()
mBackupPossible.postValue(canDo)
} else mBackupPossible.postValue(false)
if (backupAllowed) {
if (backendManager.isOnUnavailableUsb()) {
updateBackupPossible(BackupRestricted(unavailableUsb = true))
} else {
updateBackupPossible(BackupAllowed)
}
} else updateBackupPossible(BackupRestricted())
}

/**
* Updates [mBackupPossible] on the UiThread to avoid race conditions.
*/
private suspend fun updateBackupPossible(newValue: BackupPermission) {
withContext(Dispatchers.Main) {
mBackupPossible.value = newValue
}
}

private fun onStoragePropertiesChanged() {
Expand Down Expand Up @@ -221,7 +240,7 @@ internal class SettingsViewModel(
networkCallback.registered = true
}
// update whether we can do backups right now or not
onBackupRunningStateChanged()
viewModelScope.launch { onBackupRunningStateChanged() }
}

override fun onCleared() {
Expand Down Expand Up @@ -332,10 +351,20 @@ internal class SettingsViewModel(
}
}

fun loadFileBackupSize() {
viewModelScope.launch(Dispatchers.IO) {
mFilesBackupSize.postValue(storageBackup.getBackupSize())
}
}

fun checkAppBackups(percent: Int) {
AppCheckerWorker.scheduleNow(app, percent)
}

fun checkFileBackups(percent: Int) {
FileCheckerWorker.scheduleNow(app, percent)
}

fun onLogcatUriReceived(uri: Uri?) = viewModelScope.launch(Dispatchers.IO) {
if (uri == null) {
onLogcatError()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.crypto.KeyManager
import org.calyxos.backup.storage.api.StorageBackup
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

val storageModule = module {
single { StorageBackup(get(), { get<BackendManager>().backend }, get<KeyManager>()) }
single { StorageBackup(androidContext(), get<BackendManager>(), get<KeyManager>()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ 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
internal const val WARN_PERCENT = 25
internal const val WARN_BYTES = 1024 * 1024 * 1024 // 1 GB

class AppCheckFragment : Fragment() {

Expand Down
Original file line number Diff line number Diff line change
@@ -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

class FileCheckFragment : 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

v.requireViewById<TextView>(R.id.titleView).setText(R.string.settings_file_check_title)
v.requireViewById<TextView>(R.id.descriptionView).setText(R.string.settings_file_check_text)
v.requireViewById<TextView>(R.id.introView).setText(R.string.settings_file_check_text2)

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.filesBackupSize.value?.let {
Formatter.formatShortFileSize(context, (it * value / 100).toLong())
} ?: "${value.toInt()}%"
}
slider.addOnChangeListener { _, value, _ ->
onSliderChanged(value)
}

viewModel.filesBackupSize.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.filesBackupSize.removeObservers(viewLifecycleOwner)
}

v.requireViewById<Button>(R.id.startButton).setOnClickListener {
viewModel.checkFileBackups(slider.value.toInt())
requireActivity().onBackPressedDispatcher.onBackPressed()
}
return v
}

override fun onStart() {
super.onStart()
viewModel.loadFileBackupSize()
}

private fun onSliderChanged(value: Float) {
val size = viewModel.filesBackupSize.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
}
}

}
Loading

0 comments on commit 156ffbd

Please sign in to comment.