From 74322c3cc0cf9f28715c1949a6f005e4b16dcb00 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 24 May 2026 12:49:07 +0200 Subject: [PATCH 1/4] Exclude the `mmkv` module from Onyx SDK dependencies ### Build - **app/build.gradle**: Excluded `com.tencent:mmkv` from both `onyxsdk-pen` and `onyxsdk-base` dependencies. --- app/build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index f27fb057..bd39197c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -133,11 +133,13 @@ dependencies { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'appcompat-v7' exclude group: "com.onyx.android.sdk", module: "onyxsdk-geometry" + exclude group: 'com.tencent', module: 'mmkv' } implementation('com.onyx.android.sdk:onyxsdk-base:1.8.5') { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'appcompat-v7' + exclude group: 'com.tencent', module: 'mmkv' } // Temporary (?) fix for https://github.com/gaborauth/toolsboox-android/issues/305 @@ -155,7 +157,7 @@ dependencies { debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" implementation "androidx.compose.runtime:runtime-livedata:$compose_version" implementation "androidx.compose.runtime:runtime:$compose_version" - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0' implementation "androidx.navigation:navigation-compose:2.9.8" From 1cab9c51b537a9f84fb917a31149cb16c97ce866 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 24 May 2026 12:49:17 +0200 Subject: [PATCH 2/4] Update `SyncSettingsTab.kt` to improve date formatting and code style. ### UI & UX - **LastSyncInfo**: Refactored to use `LocalConfiguration.current.locales` for localized date formatting instead of the default locale. - Cleaned up trailing whitespace and improved indentation across multiple composable functions for better readability. - Reorganized parameters and line breaks in `EInkActionButton`, `EInkSection`, and various `Text` components to comply with standard formatting guidelines. --- .../notable/ui/views/SyncSettingsTab.kt | 138 ++++++++++++------ 1 file changed, 92 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt index 8a5bd659..5196151e 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -224,7 +225,9 @@ private fun ConnectionSection( isBold = true ) EInkActionButton( - text = if (state.testingConnection) stringResource(R.string.sync_testing_connection) else stringResource(R.string.sync_test_connection), + text = if (state.testingConnection) stringResource(R.string.sync_testing_connection) else stringResource( + R.string.sync_test_connection + ), onClick = callbacks.onTestConnection, enabled = !state.testingConnection && state.syncSettings.serverUrl.isNotEmpty(), modifier = Modifier.weight(1f), @@ -244,7 +247,10 @@ private fun SyncBehaviorSection( state: SyncSettingsUiState, onUpdate: (SyncSettings, Boolean) -> Unit, ) { - EInkSection(title = stringResource(R.string.sync_behavior_title), icon = Icons.Default.Settings) { + EInkSection( + title = stringResource(R.string.sync_behavior_title), + icon = Icons.Default.Settings + ) { SettingToggleRow( label = stringResource(R.string.sync_enable_label), value = state.syncSettings.syncEnabled, @@ -253,7 +259,10 @@ private fun SyncBehaviorSection( if (state.syncSettings.syncEnabled) { SettingToggleRow( - label = stringResource(R.string.sync_auto_sync_label, state.syncSettings.syncInterval), + label = stringResource( + R.string.sync_auto_sync_label, + state.syncSettings.syncInterval + ), value = state.syncSettings.autoSync, onToggle = { onUpdate(state.syncSettings.copy(autoSync = it), true) } ) @@ -280,7 +289,10 @@ private fun SyncActionsSection( state: SyncSettingsUiState, callbacks: SyncSettingsCallbacks, ) { - EInkSection(title = stringResource(R.string.sync_manual_actions_title), icon = Icons.Default.Sync) { + EInkSection( + title = stringResource(R.string.sync_manual_actions_title), + icon = Icons.Default.Sync + ) { ManualSyncButton( syncSettings = state.syncSettings, syncState = state.syncState, @@ -313,12 +325,11 @@ private fun SyncActionsSection( @Composable private fun LastSyncInfo(lastSyncTime: Long?) { val label = lastSyncTime?.let { - try { - val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) - fmt.format(java.util.Date(it)) - } catch (_: Exception) { - null - } + // Import this: androidx.compose.ui.platform.LocalConfiguration + val locale = LocalConfiguration.current.locales[0] + val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", locale) + fmt.format(java.util.Date(it)) + } val text = if (label != null) { @@ -382,16 +393,16 @@ fun EInkSection( verticalAlignment = Alignment.CenterVertically ) { Icon( - icon, - contentDescription = null, + icon, + contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.onSurface ) Spacer(modifier = Modifier.width(12.dp)) Text( - title, + title, style = MaterialTheme.typography.subtitle2, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), color = MaterialTheme.colors.onSurface ) @@ -403,7 +414,7 @@ fun EInkSection( ) } } - + AnimatedVisibility(visible = isExpanded) { Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) { content() @@ -421,13 +432,13 @@ fun ConnectionStatusText(result: AppResult) { is AppResult.Error -> Icons.Default.Warning } Icon( - icon, - contentDescription = null, + icon, + contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colors.onSurface ) Spacer(modifier = Modifier.width(4.dp)) - + val text = when (result) { is AppResult.Success -> { val skewMs = result.data.clockSkewMs @@ -437,11 +448,12 @@ fun ConnectionStatusText(result: AppResult) { stringResource(R.string.sync_connected_successfully) } } + is AppResult.Error -> result.error.userMessage } Text( - text, - style = MaterialTheme.typography.caption, + text, + style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) @@ -462,7 +474,7 @@ fun SyncCredentialFields( onValueChange = { onUpdate(settings.copy(serverUrl = it), false) }, placeholder = stringResource(R.string.sync_server_url_placeholder) ) - + if (settings.serverUrl.isNotEmpty()) { Text( text = stringResource(R.string.sync_server_url_note), @@ -483,8 +495,8 @@ fun SyncCredentialFields( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.sync_password_label), - style = MaterialTheme.typography.caption, + text = stringResource(R.string.sync_password_label), + style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) @@ -499,8 +511,8 @@ fun SyncCredentialFields( Box(modifier = Modifier.weight(1f)) { if (settings.password.isEmpty() && isPasswordSaved) { Text( - text = stringResource(R.string.sync_password_unchanged), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + text = stringResource(R.string.sync_password_unchanged), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp) ) } @@ -508,7 +520,7 @@ fun SyncCredentialFields( value = settings.password, onValueChange = { onUpdate(settings.copy(password = it), false) }, textStyle = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = FontFamily.Monospace, fontSize = 14.sp, color = MaterialTheme.colors.onSurface ), @@ -533,11 +545,16 @@ fun SyncCredentialFields( } @Composable -fun EInkTextField(label: String, value: String, onValueChange: (String) -> Unit, placeholder: String = "") { +fun EInkTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String = "" +) { Column { Text( - label, - style = MaterialTheme.typography.caption, + label, + style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) @@ -550,8 +567,8 @@ fun EInkTextField(label: String, value: String, onValueChange: (String) -> Unit, ) { if (value.isEmpty() && placeholder.isNotEmpty()) { Text( - placeholder, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.3f), + placeholder, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.3f), style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp) ) } @@ -624,6 +641,7 @@ private fun SyncIntervalSelector( ) } } + @Composable fun ManualSyncButton( syncSettings: SyncSettings, @@ -653,8 +671,8 @@ fun ManualSyncButton( Button( onClick = onManualSync, enabled = syncState is SyncState.Idle && - syncSettings.syncEnabled && - syncSettings.serverUrl.isNotEmpty(), + syncSettings.syncEnabled && + syncSettings.serverUrl.isNotEmpty(), modifier = Modifier .fillMaxWidth() .height(48.dp), @@ -699,7 +717,12 @@ private fun SyncProgressPanel(syncing: SyncState.Syncing) { horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.sync_progress_step, stepIndex, totalSteps, syncing.currentStep.displayName()), + text = stringResource( + R.string.sync_progress_step, + stepIndex, + totalSteps, + syncing.currentStep.displayName() + ), style = MaterialTheme.typography.body2, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface @@ -725,7 +748,12 @@ private fun SyncProgressPanel(syncing: SyncState.Syncing) { } syncing.item?.let { item -> Text( - text = stringResource(R.string.sync_progress_item, item.index, item.total, item.name), + text = stringResource( + R.string.sync_progress_item, + item.index, + item.total, + item.name + ), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface ) @@ -807,7 +835,7 @@ fun SyncLogViewer(syncLogs: List, onClearLog: () -> Unit) { ) { if (recentLogs.isEmpty()) { Text( - text = stringResource(R.string.sync_log_empty), + text = stringResource(R.string.sync_log_empty), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) ) @@ -816,7 +844,7 @@ fun SyncLogViewer(syncLogs: List, onClearLog: () -> Unit) { Text( text = "[${log.timestamp}] ${log.message}", style = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colors.onSurface ), @@ -849,30 +877,30 @@ fun ConfirmationDialog( ) { Column(modifier = Modifier.padding(20.dp)) { Text( - title, - fontWeight = FontWeight.Bold, + title, + fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onSurface ) Spacer(modifier = Modifier.height(12.dp)) Text( - message, + message, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface ) Spacer(modifier = Modifier.height(24.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( - onClick = onDismiss, - modifier = Modifier.weight(1f), + onClick = onDismiss, + modifier = Modifier.weight(1f), shape = EInkButtonShape, colors = eInkButtonColors(isSecondary = true) ) { Text(stringResource(R.string.sync_dialog_cancel)) } Button( - onClick = onConfirm, - modifier = Modifier.weight(1f), + onClick = onConfirm, + modifier = Modifier.weight(1f), shape = EInkButtonShape, colors = eInkButtonColors() ) { @@ -958,7 +986,16 @@ fun SyncSettingsConfiguredPreview() { syncEnabled = true, serverUrl = "https://webdav.example.com/dav/", username = "demo_user", - lastSyncTime = java.util.Calendar.getInstance().apply { set(2024, 2, 20, 14, 30, 5); set(java.util.Calendar.MILLISECOND, 0) }.timeInMillis + lastSyncTime = java.util.Calendar.getInstance().apply { + set( + 2024, + 2, + 20, + 14, + 30, + 5 + ); set(java.util.Calendar.MILLISECOND, 0) + }.timeInMillis ) ), callbacks = SyncSettingsCallbacks() @@ -984,7 +1021,16 @@ fun SyncSettingsSyncingPreview() { syncEnabled = true, serverUrl = "https://webdav.example.com/dav/", username = "demo_user", - lastSyncTime = java.util.Calendar.getInstance().apply { set(2024, 2, 20, 14, 30, 5); set(java.util.Calendar.MILLISECOND, 0) }.timeInMillis + lastSyncTime = java.util.Calendar.getInstance().apply { + set( + 2024, + 2, + 20, + 14, + 30, + 5 + ); set(java.util.Calendar.MILLISECOND, 0) + }.timeInMillis ) ), callbacks = SyncSettingsCallbacks() From eae6e32c12174eae6d1549dbdf69d3af745f55c1 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 24 May 2026 13:04:51 +0200 Subject: [PATCH 3/4] Update Polish translations and externalize sync warning strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### UI & UX - **SyncSettingsTab**: - Externalized hardcoded strings in the experimental sync warning dialog to `strings.xml`. - Added vertical scrolling to the sync settings layout. - Removed redundant comments. ### Localization - **Strings (English & Polish)**: - Added new strings for experimental sync warnings and export errors. - Fixed a typo in the Polish translation for Onyx device instructions ("Odmrizić" to "Odmrozić"). --- .../com/ethran/notable/ui/views/SyncSettingsTab.kt | 10 ++++------ app/src/main/res/values-pl/strings.xml | 8 +++++++- app/src/main/res/values/strings.xml | 6 ++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt index 5196151e..d158813d 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt @@ -119,21 +119,19 @@ fun SyncSettings( onDismissRequest = { }, title = { Text( - text = "Experimental Feature", // Replace with stringResource + text = stringResource(R.string.sync_experimental_title), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.error // Makes it red/alerting ) }, text = { - Text( - "The synchronization feature is currently in testing. It is not guaranteed to be stable and may result in the loss or duplication of data.\n\nPlease ensure you have local backups of your notes before proceeding." - ) + Text(stringResource(R.string.sync_experimental_warning)) }, confirmButton = { Button( onClick = { showWarningDialog = false } ) { - Text("I Understand & Accept the Risk") + Text(stringResource(R.string.sync_experimental_confirm)) } } ) @@ -143,6 +141,7 @@ fun SyncSettings( modifier = Modifier .fillMaxWidth() .padding(16.dp) + .verticalScroll(rememberScrollState()) ) { Text( text = stringResource(R.string.sync_title), @@ -325,7 +324,6 @@ private fun SyncActionsSection( @Composable private fun LastSyncInfo(lastSyncTime: Long?) { val label = lastSyncTime?.let { - // Import this: androidx.compose.ui.platform.LocalConfiguration val locale = LocalConfiguration.current.locales[0] val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", locale) fmt.format(java.util.Date(it)) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8e7db37e..30569272 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -82,7 +82,7 @@ ⚠️ Zapobiegaj zamrażaniu Notable (urządzenia Onyx) Urządzenia Onyx mogą automatycznie zamrażać (zamykać) aplikacje działające w tle, aby oszczędzać baterię. Jeśli nie wyłączysz tej funkcji, Notable będzie ciągle zamykany w tle, co może powodować utratę pracy. Jak zapobiec zamrażaniu: - 1. Przytrzymaj ikonę Notable na launcherze Onyx (ekran główny).\n2. Wybierz "Odmrizić" z menu. + 1. Przytrzymaj ikonę Notable na launcherze Onyx (ekran główny).\n2. Wybierz "Odmrozić" z menu. Ten krok jest wysoce zalecany dla płynnego działania. @@ -96,6 +96,7 @@ • Użyj opcji eksportowania zanacznia, aby szybko udostępniać obrazy.\n• Stuknij numer strony, aby szybko przejść do wybranej strony.\n• Spróbuj włączyć \'Zamaż aby usnąć\'(\'Scribble to Erase\') w Ustawieniach, aby naturalnie wymazywać.\n• Podwójne stuknięcie cofa, a podwójne stuknięcie na zaznaczeniu kopiuje je.\n• Możesz używać Notable jako podglądu PDF w czasie rzeczywistym dla LaTeX — zobacz README.\n• Możesz dostosować akcje gestów w Ustawieniach. Eksportuj stronę do %1$s + Błąd podczas eksportu strony do %1$s Eksportowanie strony do %1$s… Eksportuj zeszyt do %1$s @@ -220,4 +221,9 @@ Dziennik aktywności Brak ostatniej aktywności. + + Funkcja eksperymentalna + Funkcja synchronizacji jest obecnie w fazie testów. Nie ma gwarancji jej stabilności i może ona prowadzić do utraty lub duplikacji danych.\n\nPrzed kontynuowaniem upewnij się, że masz lokalne kopie zapasowe swoich notatek. + Rozumiem i akceptuję ryzyko + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa6ff936..897d9f20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ • Use \'Selection Export\' to quickly share images.\n• Tap the page number to quickly jump between pages.\n• Try enabling \'Scribble to Erase\' in Settings for natural erasing.\n• Double tap to undo, or on a selection to copy it.\n• You can use Notable as a live PDF viewer for LaTeX—see README.\n• You can customize gesture actions in Settings. Export page to %1$s + Error exporting page to %1$s Exporting the page to %1$s… Export book to %1$s @@ -203,4 +204,9 @@ Activity Log No recent activity. + + Experimental Feature + The synchronization feature is currently in testing. It is not guaranteed to be stable and may result in the loss or duplication of data.\n\nPlease ensure you have local backups of your notes before proceeding. + I Understand & Accept the Risk + From b93aab1125ef03d4c9bca2dc91acbd03b90e8f97 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 24 May 2026 13:53:24 +0200 Subject: [PATCH 4/4] Implement "Upload only" sync mode. ### Sync & Core - **SyncSettings**: Added `uploadOnly` boolean property to manage the new sync mode. - **SyncOrchestrator**: - Updated sync logic to skip remote deletions and new notebook downloads when `uploadOnly` is enabled. - Introduced `finalizeSyncResult` to handle non-critical `SyncUploadOnlySkip` errors. - **NotebookReconciliationService**: Modified `syncNotebook` to return a `SyncUploadOnlySkip` error instead of downloading when remote changes are detected in upload-only mode. - **FolderSyncService**: Updated `syncFolders` to skip applying remote folder changes to the local database when `uploadOnly` is active. - **DomainError**: Added `SyncUploadOnlySkip` error type and `isOnlyUploadSkip()` helper to identify non-critical upload skips. ### UI & Resources - **Strings**: Added labels for the "Upload only" setting in English and Polish; shortened existing WebDAV labels. - **SyncSettingsTab**: Added a toggle for the `uploadOnly` setting. ### Testing - **SyncOrchestratorTest**: Added unit tests for `finalizeSyncResult` to ensure upload-only skips are treated as successful syncs. --- .../ethran/notable/sync/FolderSyncService.kt | 20 +++-- .../sync/NotebookReconciliationService.kt | 24 ++++-- .../ethran/notable/sync/SyncOrchestrator.kt | 73 +++++++++++++++---- .../com/ethran/notable/sync/SyncSettings.kt | 2 +- .../notable/ui/views/SyncSettingsTab.kt | 6 +- .../com/ethran/notable/utils/AppResult.kt | 13 ++++ app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values/strings.xml | 5 +- .../notable/sync/SyncOrchestratorTest.kt | 39 ++++++++++ 9 files changed, 149 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt b/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt index 208a6a9e..51a9c148 100644 --- a/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt +++ b/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt @@ -16,7 +16,10 @@ class FolderSyncService @Inject constructor( ) { private val folderSerializer = FolderSerializer - suspend fun syncFolders(webdavClient: WebDAVClient): AppResult { + suspend fun syncFolders( + webdavClient: WebDAVClient, + uploadOnly: Boolean + ): AppResult { SyncLogger.i(TAG, "Syncing folders...") val localFolders = appRepository.folderRepository.getAll() val remotePath = SyncPaths.foldersFile() @@ -40,12 +43,15 @@ class FolderSyncService @Inject constructor( } val mergedFolders = folderMap.values.toList() - for (folder in mergedFolders) { - val existing = appRepository.folderRepository.get(folder.id) - if (existing != null) { - appRepository.folderRepository.update(folder) - } else { - appRepository.folderRepository.create(folder) + + if (!uploadOnly) { + for (folder in mergedFolders) { + val existing = appRepository.folderRepository.get(folder.id) + if (existing != null) { + appRepository.folderRepository.update(folder) + } else { + appRepository.folderRepository.create(folder) + } } } diff --git a/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt b/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt index 36601e95..793d7962 100644 --- a/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt +++ b/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt @@ -19,7 +19,10 @@ class NotebookReconciliationService @Inject constructor( ) { private val logger = SyncLogger - suspend fun syncExistingNotebooks(webdavClient: WebDAVClient): AppResult, DomainError> { + suspend fun syncExistingNotebooks( + webdavClient: WebDAVClient, + uploadOnly: Boolean + ): AppResult, DomainError> { val localNotebooks = appRepository.bookRepository.getAll() val preDownloadNotebookIds = localNotebooks.map { it.id }.toSet() val total = localNotebooks.size @@ -28,7 +31,7 @@ class NotebookReconciliationService @Inject constructor( localNotebooks.forEachIndexed { i, notebook -> reporter.beginItem(index = i + 1, total = total, name = notebook.title) // Individual notebook sync failures are non-fatal for the whole process - syncNotebook(notebook.id, webdavClient).onError { error -> + syncNotebook(notebook.id, webdavClient, uploadOnly).onError { error -> persistentError = persistentError?.let { it + error } ?: error } } @@ -40,7 +43,8 @@ class NotebookReconciliationService @Inject constructor( suspend fun syncNotebook( notebookId: String, - webdavClient: WebDAVClient + webdavClient: WebDAVClient, + uploadOnly: Boolean ): AppResult { logger.i(TAG, "Syncing notebook: $notebookId") @@ -70,10 +74,16 @@ class NotebookReconciliationService @Inject constructor( manifestIfMatch = remoteEtag ) - diffMs < -TIMESTAMP_TOLERANCE_MS -> notebookSyncService.downloadNotebook( - notebookId, - webdavClient - ) + diffMs < -TIMESTAMP_TOLERANCE_MS -> { + if (uploadOnly) { + AppResult.Error(DomainError.SyncUploadOnlySkip(localNotebook.title)) + } else { + notebookSyncService.downloadNotebook( + notebookId, + webdavClient + ) + } + } diffMs > TIMESTAMP_TOLERANCE_MS -> notebookSyncService.uploadNotebook( localNotebook, diff --git a/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt b/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt index 837d937a..176c9be4 100644 --- a/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt +++ b/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt @@ -51,6 +51,8 @@ class SyncOrchestrator @Inject constructor( reporter.beginStep(SyncStep.INITIALIZING, PROGRESS_INITIALIZING, "Initializing sync...") val settings = kvProxy.getSyncSettings() + val uploadOnly = settings.uploadOnly + var nonCriticalError: DomainError? = null if (!settings.syncEnabled) { val error = DomainError.SyncConfigError @@ -91,7 +93,7 @@ class SyncOrchestrator @Inject constructor( PROGRESS_SYNCING_FOLDERS, "Syncing folders..." ) - folderSyncService.syncFolders(client).onFailure { error -> + folderSyncService.syncFolders(client, uploadOnly).onFailure { error -> reporter.finishError(error, false) return@withContext AppResult.Error(error) } @@ -101,34 +103,52 @@ class SyncOrchestrator @Inject constructor( PROGRESS_APPLYING_DELETIONS, "Applying remote deletions..." ) - val tombstonedIds = + val tombstonedIds = if (uploadOnly) { + emptySet() + } else { notebookSyncService.applyRemoteDeletions(client, TOMBSTONE_MAX_AGE_DAYS) .onFailure { error -> return@withContext AppResult.Error(error) } + } reporter.beginStep( SyncStep.SYNCING_NOTEBOOKS, PROGRESS_SYNCING_NOTEBOOKS, "Syncing local notebooks..." ) - val preDownloadIds = notebookReconciliationService.syncExistingNotebooks(client) - .onFailure { error -> - return@withContext AppResult.Error(error) + val localIdsSnapshot = appRepository.bookRepository.getAll().map { it.id }.toSet() + val preDownloadIds = when ( + val syncResult = notebookReconciliationService.syncExistingNotebooks(client, uploadOnly) + ) { + is AppResult.Success -> syncResult.data + is AppResult.Error -> { + val error = syncResult.error + if (uploadOnly && error.isOnlyUploadSkip()) { + nonCriticalError = error + localIdsSnapshot + } else { + return@withContext AppResult.Error(error) + } } + } reporter.beginStep( SyncStep.DOWNLOADING_NEW, PROGRESS_DOWNLOADING_NEW, "Downloading new notebooks..." ) - val downloadedCount = notebookSyncService.downloadNewNotebooks( - client, - tombstonedIds, - settings, - preDownloadIds - ).onFailure { error -> - return@withContext AppResult.Error(error) + val downloadedCount = if (uploadOnly) { + 0 + } else { + notebookSyncService.downloadNewNotebooks( + client, + tombstonedIds, + settings, + preDownloadIds + ).onFailure { error -> + return@withContext AppResult.Error(error) + } } reporter.beginStep( @@ -152,9 +172,7 @@ class SyncOrchestrator @Inject constructor( deletedCount, System.currentTimeMillis() - startTime ) - reporter.finishSuccess(summary) - - AppResult.Success(Unit) + finalizeSyncResult(reporter, summary, nonCriticalError) } catch (e: Exception) { val error = DomainError.SyncError( @@ -188,7 +206,11 @@ class SyncOrchestrator @Inject constructor( settings.username, settings.password ) - return@withContext notebookReconciliationService.syncNotebook(notebookId, client) + return@withContext notebookReconciliationService.syncNotebook( + notebookId, + client, + settings.uploadOnly + ) } AppResult.Success(Unit) } @@ -281,6 +303,25 @@ class SyncOrchestrator @Inject constructor( } } +internal fun finalizeSyncResult( + reporter: SyncProgressReporter, + summary: SyncSummary, + nonCriticalError: DomainError? +): AppResult { + if (nonCriticalError != null && nonCriticalError.isOnlyUploadSkip()) { + reporter.finishSuccess(summary) + return AppResult.Success(Unit) + } + + if (nonCriticalError != null) { + reporter.finishError(nonCriticalError, false) + return AppResult.Error(nonCriticalError) + } + + reporter.finishSuccess(summary) + return AppResult.Success(Unit) +} + @EntryPoint @InstallIn(SingletonComponent::class) interface SyncOrchestratorEntryPoint { diff --git a/app/src/main/java/com/ethran/notable/sync/SyncSettings.kt b/app/src/main/java/com/ethran/notable/sync/SyncSettings.kt index b9355caf..67c194fc 100644 --- a/app/src/main/java/com/ethran/notable/sync/SyncSettings.kt +++ b/app/src/main/java/com/ethran/notable/sync/SyncSettings.kt @@ -15,6 +15,6 @@ data class SyncSettings( val lastSyncTime: Long? = null, val syncOnNoteClose: Boolean = true, val wifiOnly: Boolean = false, + val uploadOnly: Boolean = false, val syncedNotebookIds: Set = emptySet() ) - diff --git a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt index d158813d..c1af6be7 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt @@ -141,7 +141,6 @@ fun SyncSettings( modifier = Modifier .fillMaxWidth() .padding(16.dp) - .verticalScroll(rememberScrollState()) ) { Text( text = stringResource(R.string.sync_title), @@ -279,6 +278,11 @@ private fun SyncBehaviorSection( value = state.syncSettings.wifiOnly, onToggle = { onUpdate(state.syncSettings.copy(wifiOnly = it), true) } ) + SettingToggleRow( + label = stringResource(R.string.sync_upload_only_label), + value = state.syncSettings.uploadOnly, + onToggle = { onUpdate(state.syncSettings.copy(uploadOnly = it), true) } + ) } } } diff --git a/app/src/main/java/com/ethran/notable/utils/AppResult.kt b/app/src/main/java/com/ethran/notable/utils/AppResult.kt index 15dcbc97..9b6e48a3 100644 --- a/app/src/main/java/com/ethran/notable/utils/AppResult.kt +++ b/app/src/main/java/com/ethran/notable/utils/AppResult.kt @@ -52,6 +52,10 @@ sealed class DomainError( data object SyncWifiRequired : DomainError("WiFi connection required for sync.") data object SyncInProgress : DomainError("Sync already in progress.") data object SyncConflict : DomainError("Conflict detected during sync.") + data class SyncUploadOnlySkip(val notebookTitle: String) : DomainError( + "Remote changes detected for '$notebookTitle'. Upload-only is enabled.", + recoverable = true + ) data class SyncError( val message: String, @@ -62,6 +66,15 @@ sealed class DomainError( data class MultipleErrors(val errors: List) : DomainError( userMessage = errors.joinToString(separator = "\n") { "• ${it.userMessage}" } ) + + /** + * Checks if this error (or all errors in MultipleErrors) are SyncUploadOnlySkip. + */ + fun isOnlyUploadSkip(): Boolean = when (this) { + is SyncUploadOnlySkip -> true + is MultipleErrors -> errors.all { it is SyncUploadOnlySkip } + else -> false + } } /** diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 30569272..445883a6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -142,6 +142,7 @@ Automatyczna synchronizacja co %1$d minut Synchronizuj przy zamykaniu notatek Synchronizuj tylko przez WiFi (bez danych mobilnych) + Tylko wysyłaj (bez zmian z serwera) Interwał synchronizacji %1$dm diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 897d9f20..062a6d38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,10 +121,11 @@ (unchanged) - Enable WebDAV Sync - Automatic sync every %1$d minutes + Sync WebDAV + Auto sync every %1$d minutes Sync when closing notes Sync on WiFi only (no mobile data) + Upload only (skip remote changes) Sync interval %1$dm diff --git a/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt b/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt index 1836ed75..674abd91 100644 --- a/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt +++ b/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt @@ -3,10 +3,28 @@ package com.ethran.notable.sync import com.ethran.notable.utils.AppResult import com.ethran.notable.utils.DomainError import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class SyncOrchestratorTest { + private class FakeReporter : SyncProgressReporter { + override val state = kotlinx.coroutines.flow.MutableStateFlow(SyncState.Idle) + + override fun beginStep(step: SyncStep, stepProgress: Float, details: String) = Unit + override fun beginItem(index: Int, total: Int, name: String) = Unit + override fun endItem() = Unit + override fun reset() = Unit + + override fun finishSuccess(summary: SyncSummary) { + state.value = SyncState.Success(summary) + } + + override fun finishError(error: DomainError, canRetry: Boolean) { + state.value = SyncState.Error(error, SyncStep.INITIALIZING, canRetry) + } + } + private fun networkFailure(): AppResult = AppResult.Error(DomainError.NetworkError("Network error")) @@ -35,4 +53,25 @@ class SyncOrchestratorTest { assertEquals(1, summary.notebooksDeleted) assertEquals(1500L, summary.duration) } + + @Test + fun finalizeSyncResult_uploadOnlySkip_returns_success() { + val reporter = FakeReporter() + val summary = SyncSummary( + notebooksSynced = 2, + notebooksDownloaded = 0, + notebooksDeleted = 0, + duration = 200L + ) + + val result = finalizeSyncResult( + reporter, + summary, + DomainError.SyncUploadOnlySkip("Notes") + ) + + assertTrue(result is AppResult.Success) + assertTrue(reporter.state.value is SyncState.Success) + assertEquals(summary, (reporter.state.value as SyncState.Success).summary) + } }