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" 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 8a5bd659..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 @@ -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 @@ -118,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)) } } ) @@ -224,7 +223,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 +245,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 +257,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) } ) @@ -271,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) } + ) } } } @@ -280,7 +292,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 +328,10 @@ 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 - } + 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 +395,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 +416,7 @@ fun EInkSection( ) } } - + AnimatedVisibility(visible = isExpanded) { Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) { content() @@ -421,13 +434,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 +450,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 +476,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 +497,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 +513,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 +522,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 +547,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 +569,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 +643,7 @@ private fun SyncIntervalSelector( ) } } + @Composable fun ManualSyncButton( syncSettings: SyncSettings, @@ -653,8 +673,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 +719,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 +750,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 +837,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 +846,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 +879,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 +988,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 +1023,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() 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 8e7db37e..445883a6 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 @@ -141,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 @@ -220,4 +222,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..062a6d38 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 @@ -120,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 @@ -203,4 +205,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 + 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) + } }