diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61361d04..2a169fd9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ plugins { } val versionMajor = 2 -val versionMinor = 0 -val versionPatch = 2 +val versionMinor = 1 +val versionPatch = 0 val formattedVersionCode = (versionMajor * 10000 + versionMinor * 100 + versionPatch).toString() val formattedVersionName = "$versionMajor.$versionMinor.$versionPatch" @@ -291,6 +291,7 @@ dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.work.runtime) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) diff --git a/app/src/main/java/com/ivanovsky/passnotes/data/repository/file/remote/RemoteFileSyncProcessor.java b/app/src/main/java/com/ivanovsky/passnotes/data/repository/file/remote/RemoteFileSyncProcessor.java index fe623fba..5f24df5a 100644 --- a/app/src/main/java/com/ivanovsky/passnotes/data/repository/file/remote/RemoteFileSyncProcessor.java +++ b/app/src/main/java/com/ivanovsky/passnotes/data/repository/file/remote/RemoteFileSyncProcessor.java @@ -233,6 +233,7 @@ public OperationResult process( Timber.d("Unable to process file, no cached file"); updateProgressStatusForFile(file.getUid(), SyncProgressStatus.IDLE); + return OperationResult.error( newCacheError(MESSAGE_FAILED_TO_FIND_CACHED_FILE, new Stacktrace())); } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/App.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/App.kt index 39259427..e57be4bb 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/App.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/App.kt @@ -4,6 +4,7 @@ import android.app.Application import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl import com.ivanovsky.passnotes.domain.loggingAndReporting.CrashReporterInteractor import com.ivanovsky.passnotes.domain.loggingAndReporting.LoggerInteractor +import com.ivanovsky.passnotes.domain.worker.BackgroundSyncWorker import com.ivanovsky.passnotes.injection.AppStartDependencies import com.ivanovsky.passnotes.injection.DIModuleBuilder import com.ivanovsky.passnotes.injection.DefaultModuleBuilder @@ -55,5 +56,7 @@ open class App : Application() { androidContext(this@App) modules(moduleBuilder.buildModules()) } + + BackgroundSyncWorker.schedule(settings) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt index 538a21ba..b2cdef27 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt @@ -15,6 +15,7 @@ interface Settings { var isPostponedSyncEnabled: Boolean var autoLockDelayInMs: Int var autoClearClipboardDelayInMs: Int + var backgroundSyncIntervalInMs: Int var isLockNotificationVisible: Boolean var isLockNotificationDialogEnabled: Boolean var isLockDatabaseOnBack: Boolean diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt index 17de9c25..22b76815 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt @@ -14,6 +14,7 @@ import com.ivanovsky.passnotes.data.entity.TestToggles import com.ivanovsky.passnotes.data.repository.keepass.KeepassImplementation import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.AUTO_CLEAR_CLIPBOARD_DELAY_IN_MS import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.AUTO_LOCK_DELAY_IN_MS +import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.BACKGROUND_SYNC_INTERVAL_IN_MS import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.GIT_USER_EMAIL import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.GIT_USER_NAME import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_ACTIVATE_SEARCH_ON_START @@ -139,6 +140,13 @@ class SettingsImpl(private val context: Context) : Settings { putString(AUTO_CLEAR_CLIPBOARD_DELAY_IN_MS, value.toString()) } + override var backgroundSyncIntervalInMs: Int + get() = getString(BACKGROUND_SYNC_INTERVAL_IN_MS)?.toInt() + ?: (BACKGROUND_SYNC_INTERVAL_IN_MS.defaultValue as String).toInt() + set(value) { + putString(BACKGROUND_SYNC_INTERVAL_IN_MS, value.toString()) + } + override var searchOptions: SearchOptions get() = getString(SEARCH_OPTIONS)?.let { SearchSettingsConverter.fromString(it) @@ -374,6 +382,11 @@ class SettingsImpl(private val context: Context) : Settings { type = STRING, defaultValue = TimeUnit.SECONDS.toMillis(30).toString() ), + BACKGROUND_SYNC_INTERVAL_IN_MS( + keyId = R.string.pref_background_sync_interval_in_ms, + type = STRING, + defaultValue = TimeUnit.DAYS.toMillis(1).toString() + ), // String prefs SEARCH_OPTIONS( diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SyncUseCases.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SyncUseCases.kt index f07304eb..621069bc 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SyncUseCases.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SyncUseCases.kt @@ -1,25 +1,88 @@ package com.ivanovsky.passnotes.domain.usecases +import arrow.core.Either +import arrow.core.raise.either import com.ivanovsky.passnotes.data.entity.ConflictResolutionStrategy import com.ivanovsky.passnotes.data.entity.FileDescriptor +import com.ivanovsky.passnotes.data.entity.OperationError +import com.ivanovsky.passnotes.data.entity.OperationError.newGenericError import com.ivanovsky.passnotes.data.entity.OperationResult import com.ivanovsky.passnotes.data.entity.SyncConflictInfo import com.ivanovsky.passnotes.data.entity.SyncProgressStatus import com.ivanovsky.passnotes.data.entity.SyncState import com.ivanovsky.passnotes.data.entity.SyncStatus import com.ivanovsky.passnotes.data.repository.EncryptedDatabaseRepository +import com.ivanovsky.passnotes.data.repository.UsedFileRepository +import com.ivanovsky.passnotes.data.repository.file.FSOptions import com.ivanovsky.passnotes.data.repository.file.FileSystemResolver +import com.ivanovsky.passnotes.data.repository.file.OnConflictStrategy import com.ivanovsky.passnotes.data.repository.file.SyncStrategy import com.ivanovsky.passnotes.domain.DispatcherProvider +import com.ivanovsky.passnotes.extensions.getFileDescriptor import com.ivanovsky.passnotes.extensions.isSameFile +import com.ivanovsky.passnotes.extensions.isSyncable +import com.ivanovsky.passnotes.extensions.toEither import kotlinx.coroutines.withContext +import timber.log.Timber class SyncUseCases( private val fileSystemResolver: FileSystemResolver, private val dispatchers: DispatcherProvider, - private val dbRepo: EncryptedDatabaseRepository + private val dbRepo: EncryptedDatabaseRepository, + private val usedFileRepository: UsedFileRepository ) { + suspend fun syncChanges(): Either = + withContext(dispatchers.IO) { + either { + val syncableFiles = usedFileRepository.getAll() + .map { file -> file.getFileDescriptor() } + .filter { file -> file.fsAuthority.isSyncable() } + + for (file in syncableFiles) { + val provider = fileSystemResolver.resolveProvider(file.fsAuthority) + val cachedFile = provider.syncProcessor.getCachedFile(file.uid) + + if (cachedFile == null) { + // The file isn't downloaded, it should be downloaded first + Timber.d( + "Syncing file: file=%s, fsType=%s".format( + file.path, + file.fsAuthority.type + ) + ) + + val content = provider.openFileForRead( + file, + OnConflictStrategy.CANCEL, + FSOptions.READ_ONLY + ).toEither().bind() + + Either.catch { content.close() } + .mapLeft { error -> newGenericError(error) } + .bind() + } else { + val syncState = getSyncState(file) + + val hasRemoteChanges = (syncState.status == SyncStatus.REMOTE_CHANGES) + val hasLocalChanges = (syncState.status == SyncStatus.LOCAL_CHANGES) + + Timber.d( + "Syncing file: syncState=%s, file=%s, fsType=%s".format( + syncState, + file.path, + file.fsAuthority.type + ) + ) + + if (hasRemoteChanges || hasLocalChanges) { + processSync(file).toEither().bind() + } + } + } + } + } + suspend fun getSyncConflictInfo(file: FileDescriptor): OperationResult = withContext(dispatchers.IO) { fileSystemResolver diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/worker/BackgroundSyncWorker.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/worker/BackgroundSyncWorker.kt new file mode 100644 index 00000000..e258ca8a --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/worker/BackgroundSyncWorker.kt @@ -0,0 +1,82 @@ +package com.ivanovsky.passnotes.domain.worker + +import android.content.Context +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.ivanovsky.passnotes.data.repository.settings.Settings +import com.ivanovsky.passnotes.domain.usecases.SyncUseCases +import com.ivanovsky.passnotes.injection.GlobalInjector +import com.ivanovsky.passnotes.injection.GlobalInjector.inject +import java.util.concurrent.TimeUnit +import timber.log.Timber + +class BackgroundSyncWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + private val syncUseCases: SyncUseCases by inject() + + override suspend fun doWork(): Result { + Timber.d("Periodic sync worker started") + + return syncUseCases.syncChanges().fold( + ifLeft = { error -> + Timber.e("Sync failed: %s".format(error)) + Timber.e(error.throwable) + + Result.retry() + }, + ifRight = { + Timber.d("Sync finished successfully") + + Result.success() + } + ) + } + + companion object { + private const val BACKGROUND_SYNC_WORKER_NAME = "background-sync-worker" + + fun schedule(settings: Settings) { + val context: Context = GlobalInjector.get() + val interval = settings.backgroundSyncIntervalInMs + + Timber.d("Schedule background worker: interval=%s".format(interval)) + + if (interval == -1) { + WorkManager.getInstance(context).cancelUniqueWork(BACKGROUND_SYNC_WORKER_NAME) + return + } + + val constraints = Constraints.Builder() + .setRequiredNetworkRequest( + NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build(), + NetworkType.CONNECTED + ) + .build() + + val request = PeriodicWorkRequestBuilder( + interval.toLong(), + TimeUnit.MILLISECONDS + ) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + BACKGROUND_SYNC_WORKER_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSAuthorityExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSAuthorityExt.kt new file mode 100644 index 00000000..476bc509 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSAuthorityExt.kt @@ -0,0 +1,7 @@ +package com.ivanovsky.passnotes.extensions + +import com.ivanovsky.passnotes.data.entity.FSAuthority + +fun FSAuthority.isSyncable(): Boolean { + return credentials != null +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FsTypeExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSTypeExt.kt similarity index 96% rename from app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FsTypeExt.kt rename to app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSTypeExt.kt index d0faf1ac..ff7c00d6 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FsTypeExt.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/FSTypeExt.kt @@ -14,4 +14,6 @@ fun FSType.getLoginType(): LoginType? { FSType.FAKE -> LoginType.USERNAME_PASSWORD else -> null } -} \ No newline at end of file +} + +// fun FSType.is \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt index ce37de07..c50975d2 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt @@ -40,7 +40,7 @@ object UseCaseModule { single { GetTestPasswordUseCase(get(), get()) } single { LockDatabaseUseCase() } single { GetRecentlyOpenedFilesUseCase(get(), get()) } - single { SyncUseCases(get(), get(), get()) } + single { SyncUseCases(get(), get(), get(), get()) } single { AddTemplatesUseCase(get(), get(), get()) } single { GetDatabaseUseCase(get()) } single { MoveNoteUseCase(get(), get(), get()) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/SelectorDialogScreen.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/SelectorDialogScreen.kt index eb17f5f3..3bbe8da1 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/SelectorDialogScreen.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/SelectorDialogScreen.kt @@ -138,13 +138,15 @@ private fun SelectorItem( text = item.title, style = PrimaryTextStyle() ) - Text( - text = item.description, - style = SecondaryTextStyle( - fontSize = AppTheme.theme.textMetrics.secondary - ), - modifier = Modifier.padding(top = QuarterMargin) - ) + item.description?.let { description -> + Text( + text = description, + style = SecondaryTextStyle( + fontSize = AppTheme.theme.textMetrics.secondary + ), + modifier = Modifier.padding(top = QuarterMargin) + ) + } } } } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/model/SelectorDialogItem.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/model/SelectorDialogItem.kt index 8def12af..b90a97e6 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/model/SelectorDialogItem.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/selectorDialog/model/SelectorDialogItem.kt @@ -6,5 +6,5 @@ import kotlinx.parcelize.Parcelize @Parcelize data class SelectorDialogItem( val title: String, - val description: String + val description: String? = null ) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt index a4367907..f71282b2 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt @@ -14,6 +14,7 @@ import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.data.repository.settings.Settings import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.AUTO_CLEAR_CLIPBOARD_DELAY_IN_MS import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.AUTO_LOCK_DELAY_IN_MS +import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.BACKGROUND_SYNC_INTERVAL_IN_MS import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_ACTIVATE_SEARCH_ON_START import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_BIOMETRIC_UNLOCK_ENABLED import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_CRASH_REPORTING_ENABLED @@ -47,6 +48,7 @@ class AppSettingsFragment : BasePreferenceFragment(), PermissionRequestResultRec private lateinit var isFileLogEnabledPref: SwitchPreferenceCompat private lateinit var isCrashReportingEnabledPref: SwitchPreferenceCompat + private lateinit var backgroundSyncIntervalPref: Preference private lateinit var isPostponedSyncEnabledPref: SwitchPreferenceCompat private lateinit var isBiometricUnlockEnabledPref: SwitchPreferenceCompat private lateinit var keepassImplementationPref: Preference @@ -88,6 +90,7 @@ class AppSettingsFragment : BasePreferenceFragment(), PermissionRequestResultRec IS_LOCK_DATABASE_ON_BACK, AUTO_LOCK_DELAY_IN_MS, AUTO_CLEAR_CLIPBOARD_DELAY_IN_MS, + BACKGROUND_SYNC_INTERVAL_IN_MS, IS_FILE_LOG_ENABLED, IS_CRASH_REPORTING_ENABLED, IS_POSTPONED_SYNC_ENABLED, @@ -113,6 +116,11 @@ class AppSettingsFragment : BasePreferenceFragment(), PermissionRequestResultRec ) ?: throwPreferenceNotFound(R.string.pref_is_crash_reporting_enabled) + backgroundSyncIntervalPref = findPreference( + getString(R.string.pref_background_sync_interval_in_ms) + ) + ?: throwPreferenceNotFound(R.string.pref_background_sync_interval_in_ms) + isPostponedSyncEnabledPref = findPreference( getString(R.string.pref_is_postponed_sync_enabled) ) @@ -150,6 +158,11 @@ class AppSettingsFragment : BasePreferenceFragment(), PermissionRequestResultRec categoryLogging = findPreference(getString(R.string.pref_category_reporting)) ?: throwPreferenceNotFound(R.string.pref_category_reporting) + backgroundSyncIntervalPref.setOnPreferenceChangeListener { _, newValue -> + viewModel.onBackgroundSyncIntervalSelected((newValue as String).toInt()) + true + } + isFileLogEnabledPref.setOnPreferenceChangeListener { _, newValue -> viewModel.onFileLogEnabledChanged(newValue as Boolean) true diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsViewModel.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsViewModel.kt index 71634746..921545c6 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsViewModel.kt @@ -13,6 +13,7 @@ import com.ivanovsky.passnotes.domain.biometric.BiometricResolver import com.ivanovsky.passnotes.domain.entity.SystemPermission import com.ivanovsky.passnotes.domain.interactor.settings.app.AppSettingsInteractor import com.ivanovsky.passnotes.domain.loggingAndReporting.CrashReporterInteractor.CrashReporterAvailability +import com.ivanovsky.passnotes.domain.worker.BackgroundSyncWorker import com.ivanovsky.passnotes.extensions.formatReadableMessage import com.ivanovsky.passnotes.presentation.core.dialog.selectorDialog.SelectorDialogArgs import com.ivanovsky.passnotes.presentation.core.dialog.selectorDialog.model.SelectorDialogItem @@ -142,6 +143,16 @@ class AppSettingsViewModel( interactor.lockDatabase() } + fun onBackgroundSyncIntervalSelected(intervalInMs: Int) { + val isChanged = (settings.backgroundSyncIntervalInMs != intervalInMs) + + settings.backgroundSyncIntervalInMs = intervalInMs + + if (isChanged) { + BackgroundSyncWorker.schedule(settings) + } + } + fun onSendLongFileClicked() { isLoading.value = true diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 804fdeb6..8c02f6c4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -357,6 +357,8 @@ Очистити журнали Відкласти вивантаження Вивантаження даних через мережу буде відкладено до блокування бази даних. + Фонова синхронізація + Як часто застосунок перевіряє відкриті бази даних на наявність віддалених оновлень через Wi-Fi Ім\'я користувача Git Це ім\'я користувача буде використано в повідомленні про коміт. @@ -403,4 +405,4 @@ Перейти до проблем ...Показати стек викликів... - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3bd6e211..b3ba4540 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -329,6 +329,8 @@ 清除日志 延迟上传 通过网络上传数据的操作将延迟至数据库锁定后执行 + 后台同步 + 应用通过 Wi-Fi 检查已打开数据库远程更新的频率 Git用户名 将用于提交信息中的用户名 Git用户邮箱 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c53973e..06cff9f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,6 +375,8 @@ Clear logs Postpone uploading Uploading data via network will be postponed until database will be locked + Background synchronization + How often the app checks opened databases for remote updates over Wi-Fi Git user name The user name will be used in the commit message diff --git a/app/src/main/res/values/untranslatable-strings.xml b/app/src/main/res/values/untranslatable-strings.xml index b00a651f..8cf11d09 100644 --- a/app/src/main/res/values/untranslatable-strings.xml +++ b/app/src/main/res/values/untranslatable-strings.xml @@ -26,6 +26,7 @@ passwordGeneratorSettings testAutofillData testToggles + backgroundSyncInterval databaseSettingsFragment isRecycleBinEnabled @@ -93,6 +94,26 @@ 300000 + + 15 minutes + 1 hour + 2 hours + 6 hours + 12 hours + Once a day + Manual only + + + + 900000 + 3600000 + 7200000 + 21600000 + 43200000 + 86400000 + -1 + + Strict(exact matching) Fuzzy(approximate matching) diff --git a/app/src/main/res/xml/application_settings.xml b/app/src/main/res/xml/application_settings.xml index 0394b047..9b0a1e24 100644 --- a/app/src/main/res/xml/application_settings.xml +++ b/app/src/main/res/xml/application_settings.xml @@ -36,6 +36,13 @@ app:key="@string/pref_category_synchronization" app:title="@string/synchronization_with_server"> + +