diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index c65dc9f7..af6ac99a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -9,7 +9,10 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.ServiceManager.getService import android.os.StrictMode +import android.os.UserHandle import android.os.UserManager +import android.provider.Settings +import androidx.work.WorkManager import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.MetadataManager @@ -28,6 +31,7 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel +import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.workerModule import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext @@ -43,6 +47,8 @@ import org.koin.dsl.module */ open class App : Application() { + open val isTest: Boolean = false + private val appModule = module { single { SettingsManager(this@App) } single { BackupNotificationManager(this@App) } @@ -79,6 +85,7 @@ open class App : Application() { permitDiskReads { migrateTokenFromMetadataToSettingsManager() } + if (!isTest) migrateToOwnScheduling() } protected open fun startKoin() = startKoin { @@ -102,6 +109,7 @@ open class App : Application() { private val settingsManager: SettingsManager by inject() private val metadataManager: MetadataManager by inject() + private val backupManager: IBackupManager by inject() /** * The responsibility for the current token was moved to the [SettingsManager] @@ -117,6 +125,23 @@ open class App : Application() { } } + /** + * Disables the framework scheduling in favor of our own. + * Introduced in the first half of 2024 and can be removed after a suitable migration period. + */ + protected open fun migrateToOwnScheduling() { + if (!isFrameworkSchedulingEnabled()) return // already on own scheduling + + backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) + if (backupManager.isBackupEnabled) AppBackupWorker.schedule(applicationContext) + // cancel old D2D worker + WorkManager.getInstance(this).cancelUniqueWork("APP_BACKUP") + } + + private fun isFrameworkSchedulingEnabled(): Boolean = Settings.Secure.getInt( + contentResolver, Settings.Secure.BACKUP_SCHEDULING_ENABLED, 1 + ) == 1 // 1 means enabled which is the default + } const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt index b1281325..c7e7d378 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt @@ -44,8 +44,7 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { val d2dPreference = findPreference(PREF_KEY_D2D_BACKUPS) d2dPreference?.setOnPreferenceChangeListener { _, newValue -> - viewModel.onD2dChanged(newValue as Boolean) - d2dPreference.isChecked = newValue + d2dPreference.isChecked = newValue as Boolean // automatically enable unlimited quota when enabling D2D backups if (d2dPreference.isChecked) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 466107c0..08648a8e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -23,6 +23,7 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.toRelativeTime +import com.stevesoltys.seedvault.worker.AppBackupWorker import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -125,8 +126,9 @@ class SettingsFragment : PreferenceFragmentCompat() { backupStorage = findPreference("backup_storage")!! backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> val disable = !(newValue as Boolean) + // TODO this should really get moved out off the UI layer if (disable) { - viewModel.disableStorageBackup() + viewModel.cancelBackupWorkers() return@OnPreferenceChangeListener true } onEnablingStorageBackup() @@ -208,10 +210,16 @@ class SettingsFragment : PreferenceFragmentCompat() { else -> super.onOptionsItemSelected(item) } + // TODO this should really get moved out off the UI layer private fun trySetBackupEnabled(enabled: Boolean): Boolean { return try { backupManager.isBackupEnabled = enabled - if (enabled) viewModel.enableCallLogBackup() + if (enabled) { + AppBackupWorker.schedule(requireContext()) + viewModel.enableCallLogBackup() + } else { + AppBackupWorker.unschedule(requireContext()) + } backup.isChecked = enabled true } catch (e: RemoteException) { @@ -307,7 +315,7 @@ class SettingsFragment : PreferenceFragmentCompat() { LENGTH_LONG ).show() } - viewModel.enableStorageBackup() + viewModel.scheduleBackupWorkers() backupStorage.isChecked = true dialog.dismiss() } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index d241e919..eda7c4f7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -12,7 +12,6 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.Uri import android.os.Process.myUid -import android.os.UserHandle import android.provider.Settings import android.util.Log import android.widget.Toast @@ -92,19 +91,19 @@ internal class SettingsViewModel( private val storageObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) { - onStorageLocationChanged() + onStoragePropertiesChanged() } } private inner class NetworkObserver : ConnectivityManager.NetworkCallback() { var registered = false override fun onAvailable(network: Network) { - onStorageLocationChanged() + onStoragePropertiesChanged() } override fun onLost(network: Network) { super.onLost(network) - onStorageLocationChanged() + onStoragePropertiesChanged() } } @@ -119,13 +118,29 @@ internal class SettingsViewModel( // ensures the lastBackupTime LiveData gets set metadataManager.getLastBackupTime() } - onStorageLocationChanged() + onStoragePropertiesChanged() loadFilesSummary() } override fun onStorageLocationChanged() { val storage = settingsManager.getStorage() ?: return + Log.i(TAG, "onStorageLocationChanged") + if (storage.isUsb) { + // disable storage backup if new storage is on USB + cancelBackupWorkers() + } else { + // enable it, just in case the previous storage was on USB, + // also to update the network requirement of the new storage + scheduleBackupWorkers() + } + onStoragePropertiesChanged() + } + + private fun onStoragePropertiesChanged() { + val storage = settingsManager.getStorage() ?: return + + Log.d(TAG, "onStoragePropertiesChanged") // register storage observer try { contentResolver.unregisterContentObserver(storageObserver) @@ -148,14 +163,6 @@ internal class SettingsViewModel( networkCallback.registered = true } - if (settingsManager.isStorageBackupEnabled()) { - // disable storage backup if new storage is on USB - if (storage.isUsb) disableStorageBackup() - // enable it, just in case the previous storage was on USB, - // also to update the network requirement of the new storage - else enableStorageBackup() - } - viewModelScope.launch(Dispatchers.IO) { val canDo = settingsManager.canDoBackupNow() mBackupPossible.postValue(canDo) @@ -231,20 +238,24 @@ internal class SettingsViewModel( return keyManager.hasMainKey() } - fun enableStorageBackup() { + fun scheduleBackupWorkers() { val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb) BackupJobService.scheduleJob( - context = app, - jobServiceClass = StorageBackupJobService::class.java, - periodMillis = HOURS.toMillis(24), - networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED - else NETWORK_TYPE_NONE, - deviceIdle = false, - charging = true - ) + if (!storage.isUsb) { + if (backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob( + context = app, + jobServiceClass = StorageBackupJobService::class.java, + periodMillis = HOURS.toMillis(24), + networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED + else NETWORK_TYPE_NONE, + deviceIdle = false, + charging = true + ) + } } - fun disableStorageBackup() { + fun cancelBackupWorkers() { + AppBackupWorker.unschedule(app) BackupJobService.cancelJob(app) } @@ -272,13 +283,4 @@ internal class SettingsViewModel( Toast.makeText(app, str, LENGTH_LONG).show() } - fun onD2dChanged(enabled: Boolean) { - backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled) - if (enabled) { - AppBackupWorker.schedule(app) - } else { - AppBackupWorker.unschedule(app) - } - } - } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index 43f53d95..96ac383f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -83,6 +83,19 @@ class AppBackupWorker( } catch (e: Exception) { Log.e(TAG, "Error while running setForeground: ", e) } + return try { + doBackup() + } finally { + // schedule next backup, because the old one gets lost + // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() + if (tags.contains(TAG_NOW) && backupRequester.isBackupEnabled) { + // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled + schedule(applicationContext, CANCEL_AND_REENQUEUE) + } + } + } + + private suspend fun doBackup(): Result { var result: Result = Result.success() try { Log.i(TAG, "Starting APK backup...") @@ -92,19 +105,10 @@ class AppBackupWorker( result = Result.retry() } finally { Log.i(TAG, "Requesting app data backup...") - val requestSuccess = try { - if (backupRequester.isBackupEnabled) { - Log.d(TAG, "Backup is enabled, request backup...") - backupRequester.requestBackup() - } else true - } finally { - // schedule next backup, because the old one gets lost - // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() - if (tags.contains(TAG_NOW)) { - // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled - schedule(applicationContext, CANCEL_AND_REENQUEUE) - } - } + val requestSuccess = if (backupRequester.isBackupEnabled) { + Log.d(TAG, "Backup is enabled, request backup...") + backupRequester.requestBackup() + } else true if (!requestSuccess) result = Result.retry() } return result diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt index cdf03aea..41d6e53b 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt @@ -19,6 +19,8 @@ import org.koin.dsl.module class TestApp : App() { + override val isTest: Boolean = true + private val testCryptoModule = module { factory { CipherFactoryImpl(get()) } single { KeyManagerTestImpl() }