From 81e1bc19e47f2699bcbc2e7656ced9567aa8543a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Oct 2026 09:10:11 +0200 Subject: [PATCH 1/7] expose 423 locked error Signed-off-by: alperozturk96 --- .../notes/exception/NoteLockedException.kt | 10 +++++++++ .../owncloud/notes/main/MainActivity.java | 3 ++- .../owncloud/notes/main/SyncErrorMessage.kt | 21 ++++++++++++++++++ .../notes/persistence/FailedSyncResponse.kt | 22 +++++++++++++++++++ .../persistence/NotesServerSyncTask.java | 5 +++-- app/src/main/res/values/strings.xml | 1 + 6 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt b/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt new file mode 100644 index 000000000..5dad95763 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.exception + +class NoteLockedException(noteTitle: String) : + Exception("\"$noteTitle\" is locked on the server, it is probably open in another editor") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index ed7b84ca6..ee3854fd6 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -2,6 +2,7 @@ * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2015-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.main; @@ -215,7 +216,7 @@ protected void onCreate(Bundle savedInstanceState) { mainViewModel.getSyncStatus().observe(this, syncStatus -> swipeRefreshLayout.setRefreshing(syncStatus)); mainViewModel.getSyncErrors().observe(this, exceptions -> { if (mainViewModel.containsNonInfrastructureRelatedItems(exceptions)) { - BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(exceptions), Snackbar.LENGTH_LONG) .setAnchorView(binding.activityNotesListView.fabCreate) .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(exceptions) .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt new file mode 100644 index 000000000..2e53f05da --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.main + +import androidx.annotation.StringRes +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.exception.NoteLockedException + +object SyncErrorMessage { + @JvmStatic + @StringRes + fun of(exceptions: Collection): Int = if (exceptions.all { it is NoteLockedException }) { + R.string.error_note_locked + } else { + R.string.error_synchronization + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt new file mode 100644 index 000000000..f156de2b6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence + +import it.niedermann.owncloud.notes.exception.NoteLockedException +import it.niedermann.owncloud.notes.persistence.entity.Note +import retrofit2.Response + +object FailedSyncResponse { + private const val HTTP_LOCKED = 423 + + @JvmStatic + fun toException(note: Note, response: Response<*>): Exception = if (response.code() == HTTP_LOCKED) { + NoteLockedException(note.title) + } else { + Exception(response.message()) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index 03d151a77..92e97dd40 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -2,6 +2,7 @@ * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.persistence; @@ -147,7 +148,7 @@ private boolean pushLocalChanges() { throw new Exception(createResponse.message()); } } else { - throw new Exception(editResponse.message()); + throw FailedSyncResponse.toException(note, editResponse); } } else { Log.v(TAG, " ...Note does not have a remoteId yet → create"); @@ -176,7 +177,7 @@ private boolean pushLocalChanges() { if (deleteResponse.code() == HTTP_NOT_FOUND) { Log.v(TAG, " ...delete (note has already been deleted remotely)"); } else { - throw new Exception(deleteResponse.message()); + throw FailedSyncResponse.toException(note, deleteResponse); } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80d4284fc..dae0475d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,6 +195,7 @@ Synchronization failed: %1$s Synchronization failed + A note is open in another app. Close it there to sync your changes. No network connection Server is in maintenance mode An unknown error has occurred. From 3639ea3629fdda11a4f2bdfdab10288cca7726e6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Oct 2026 09:36:34 +0200 Subject: [PATCH 2/7] expose other errors Signed-off-by: alperozturk96 --- .../owncloud/notes/main/SyncErrorMessage.kt | 27 ++++- .../notes/persistence/NoteSyncException.kt | 14 +++ .../notes/persistence/RemoteNotesMerger.kt | 109 ++++++++++++++++++ .../notes/persistence/SyncFailureReason.kt | 45 ++++++++ app/src/main/res/values/strings.xml | 9 +- 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSyncException.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt index 2e53f05da..8cf93ff6a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt @@ -7,15 +7,30 @@ package it.niedermann.owncloud.notes.main import androidx.annotation.StringRes -import it.niedermann.owncloud.notes.R -import it.niedermann.owncloud.notes.exception.NoteLockedException +import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException +import com.nextcloud.android.sso.exceptions.NextcloudNetworkException +import it.niedermann.owncloud.notes.persistence.NoteSyncException +import it.niedermann.owncloud.notes.persistence.SyncFailureReason object SyncErrorMessage { @JvmStatic @StringRes - fun of(exceptions: Collection): Int = if (exceptions.all { it is NoteLockedException }) { - R.string.error_note_locked - } else { - R.string.error_synchronization + fun of(exceptions: Collection): Int = + (exceptions.map { it.syncFailureReason() }.distinct().singleOrNull() ?: SyncFailureReason.UNKNOWN).message + + @JvmStatic + @StringRes + fun of(exception: Throwable): Int = exception.syncFailureReason().message + + private fun Throwable.syncFailureReason(): SyncFailureReason = + generateSequence(this) { it.cause }.firstNotNullOfOrNull { it.directSyncFailureReason() } + ?: SyncFailureReason.UNKNOWN + + private fun Throwable.directSyncFailureReason(): SyncFailureReason? = when (this) { + is NoteSyncException -> reason + is NextcloudHttpRequestFailedException -> SyncFailureReason.fromStatusCode(statusCode) + is NextcloudNetworkException, is NextcloudApiNotRespondingException -> SyncFailureReason.SERVER_UNREACHABLE + else -> null } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSyncException.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSyncException.kt new file mode 100644 index 000000000..4a666292a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSyncException.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence + +import retrofit2.Response + +class NoteSyncException(noteTitle: String, response: Response<*>) : + Exception("Could not sync \"$noteTitle\" (HTTP ${response.code()}): ${response.message()}") { + val reason = SyncFailureReason.fromStatusCode(response.code()) +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt new file mode 100644 index 000000000..9cc20286e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt @@ -0,0 +1,109 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence + +import android.util.Log +import com.nextcloud.android.sso.api.ParsedResponse +import it.niedermann.owncloud.notes.BuildConfig +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.shared.model.DBStatus +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil +import it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt +import java.util.Calendar +import java.util.Date + +class RemoteNotesMerger(private val repo: NotesRepository, private val localAccount: Account) { + + companion object { + private val TAG = RemoteNotesMerger::class.java.simpleName + private const val HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions" + private const val HEADER_KEY_ETAG = "ETag" + private const val HEADER_KEY_LAST_MODIFIED = "Last-Modified" + } + + fun merge(fetchResponse: ParsedResponse>, idMap: Map) { + val remoteIds = applyRemoteNotes(fetchResponse.response, idMap) + removeRemotelyDeletedNotes(idMap, remoteIds) + updateSyncState(fetchResponse.headers) + } + + private fun applyRemoteNotes(remoteNotes: List, idMap: Map): Set { + val remoteIds = HashSet() + for (remoteNote in remoteNotes) { + Log.v(TAG, " Process Remote Note: " + if (BuildConfig.DEBUG) remoteNote else remoteNote.title) + remoteIds.add(remoteNote.remoteId) + applyRemoteNote(remoteNote, idMap) + } + return remoteIds + } + + private fun applyRemoteNote(remoteNote: Note, idMap: Map) { + val modified = remoteNote.modified + if (modified == null) { + Log.v(TAG, " ... unchanged") + return + } + if (!idMap.containsKey(remoteNote.remoteId)) { + Log.v(TAG, " ... create") + repo.addNote(localAccount.id, remoteNote) + return + } + + Log.v(TAG, " ... found → Update") + val localId = idMap[remoteNote.remoteId] + if (localId == null) { + val note = if (BuildConfig.DEBUG) remoteNote else remoteNote.title + Log.e(TAG, "Tried to update note from server, but local id of note is null. $note") + return + } + repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( + localId, + modified.timeInMillis, + remoteNote.title, + remoteNote.favorite, + remoteNote.category, + remoteNote.eTag, + remoteNote.content, + generateNoteExcerpt(remoteNote.content, remoteNote.title) + ) + } + + private fun removeRemotelyDeletedNotes(idMap: Map, remoteIds: Set) { + Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)") + for ((remoteId, localId) in idMap) { + if (!remoteIds.contains(remoteId)) { + Log.v(TAG, " ... remove $localId") + repo.deleteByNoteId(localId, DBStatus.VOID) + } + } + } + + private fun updateSyncState(headers: Map) { + localAccount.eTag = headers[HEADER_KEY_ETAG] + + val lastModified = parseLastModified(headers[HEADER_KEY_LAST_MODIFIED]) + Log.d(TAG, "ETag: ${headers[HEADER_KEY_ETAG]}; Last-Modified: $lastModified ($lastModified)") + localAccount.modified = lastModified + + repo.updateETag(localAccount.id, localAccount.eTag) + repo.updateModified(localAccount.id, lastModified.timeInMillis) + + val newApiVersion = ApiVersionUtil.sanitize(headers[HEADER_KEY_X_NOTES_API_VERSIONS]) + localAccount.apiVersion = newApiVersion + repo.updateApiVersion(localAccount.id, newApiVersion) + Log.d(TAG, "ApiVersion: $newApiVersion") + } + + @Suppress("DEPRECATION") + private fun parseLastModified(lastModifiedHeader: String?): Calendar = Calendar.getInstance().apply { + timeInMillis = 0 + if (lastModifiedHeader != null) { + timeInMillis = Date.parse(lastModifiedHeader) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt new file mode 100644 index 000000000..ca37806a5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence + +import androidx.annotation.StringRes +import it.niedermann.owncloud.notes.R +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_INTERNAL_ERROR +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_UNAUTHORIZED +import java.net.HttpURLConnection.HTTP_UNAVAILABLE + +enum class SyncFailureReason(@StringRes val message: Int) { + UNAUTHORIZED(R.string.error_sync_unauthorized), + READ_ONLY(R.string.error_sync_read_only), + NOTES_APP_UNAVAILABLE(R.string.error_sync_notes_app_unavailable), + LOCKED(R.string.error_sync_locked), + SERVER_ERROR(R.string.error_sync_server_error), + MAINTENANCE(R.string.error_sync_maintenance), + INSUFFICIENT_STORAGE(R.string.error_sync_insufficient_storage), + SERVER_UNREACHABLE(R.string.error_sync_server_unreachable), + UNKNOWN(R.string.error_synchronization); + + companion object { + private const val HTTP_LOCKED = 423 + private const val HTTP_INSUFFICIENT_STORAGE = 507 + private const val SSO_LOCAL_FAILURE = 900 + + fun fromStatusCode(statusCode: Int): SyncFailureReason = when (statusCode) { + HTTP_UNAUTHORIZED -> UNAUTHORIZED + HTTP_FORBIDDEN -> READ_ONLY + HTTP_NOT_FOUND -> NOTES_APP_UNAVAILABLE + HTTP_LOCKED -> LOCKED + HTTP_INTERNAL_ERROR -> SERVER_ERROR + HTTP_UNAVAILABLE -> MAINTENANCE + HTTP_INSUFFICIENT_STORAGE -> INSUFFICIENT_STORAGE + SSO_LOCAL_FAILURE -> SERVER_UNREACHABLE + else -> UNKNOWN + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dae0475d0..a48ecf8f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,7 +195,14 @@ Synchronization failed: %1$s Synchronization failed - A note is open in another app. Close it there to sync your changes. + A note is open in another app. Close it there to sync your changes. + Your login is no longer valid. Log in again in the Nextcloud Files app. + A note is read-only on the server. Your changes could not be saved. + The Notes app is not available on your server. + The server could not save your changes. Ask your administrator to check the logs. + The server is in maintenance mode. Your changes will sync later. + Your Nextcloud storage is full. Free up space to sync your changes. + Could not reach your server. Check your connection and the Nextcloud Files app. No network connection Server is in maintenance mode An unknown error has occurred. From bf1aa07ba5c2f7071274ab8b53ea5ed48469590a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Oct 2026 09:36:46 +0200 Subject: [PATCH 3/7] refactor server sync task Signed-off-by: alperozturk96 --- .../notes/exception/NoteLockedException.kt | 10 - .../owncloud/notes/main/MainActivity.java | 4 +- .../notes/persistence/FailedSyncResponse.kt | 22 -- .../persistence/NotesServerSyncTask.java | 300 ++++++++---------- 4 files changed, 143 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt delete mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt b/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt deleted file mode 100644 index 5dad95763..000000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/exception/NoteLockedException.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package it.niedermann.owncloud.notes.exception - -class NoteLockedException(noteTitle: String) : - Exception("\"$noteTitle\" is locked on the server, it is probably open in another editor") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index ee3854fd6..2f8fe3855 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -325,7 +325,7 @@ public void onError(@NonNull Throwable t) { .setAnchorView(binding.activityNotesListView.fabCreate) .show(); } else { - BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(t), Snackbar.LENGTH_LONG) .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) .setAnchorView(binding.activityNotesListView.fabCreate) @@ -609,7 +609,7 @@ public void onError(@NonNull Throwable t) { .setAnchorView(binding.activityNotesListView.fabCreate) .show(); } else { - BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(t), Snackbar.LENGTH_LONG) .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) .setAnchorView(binding.activityNotesListView.fabCreate) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt deleted file mode 100644 index f156de2b6..000000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/FailedSyncResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package it.niedermann.owncloud.notes.persistence - -import it.niedermann.owncloud.notes.exception.NoteLockedException -import it.niedermann.owncloud.notes.persistence.entity.Note -import retrofit2.Response - -object FailedSyncResponse { - private const val HTTP_LOCKED = 423 - - @JvmStatic - fun toException(note: Note, response: Response<*>): Exception = if (response.code() == HTTP_LOCKED) { - NoteLockedException(note.title) - } else { - Exception(response.message()) - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index 92e97dd40..c3ef83ec1 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -28,10 +28,7 @@ import com.owncloud.android.lib.common.utils.Log_OC; import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; @@ -39,10 +36,10 @@ import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; -import it.niedermann.owncloud.notes.shared.model.DBStatus; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; +import retrofit2.Response; /** @@ -53,10 +50,6 @@ abstract class NotesServerSyncTask extends Thread { private static final String TAG = NotesServerSyncTask.class.getSimpleName(); - private static final String HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions"; - private static final String HEADER_KEY_ETAG = "ETag"; - private static final String HEADER_KEY_LAST_MODIFIED = "Last-Modified"; - private NotesAPI notesAPI; @NonNull private final ApiProvider apiProvider; @@ -118,93 +111,109 @@ private boolean pushLocalChanges() { Log.d(TAG, "pushLocalChanges()"); boolean success = true; - final var notes = repo.getLocalModifiedNotes(localAccount.getId()); - for (Note note : notes) { + for (final var note : repo.getLocalModifiedNotes(localAccount.getId())) { Log.d(TAG, " Process Local Note: " + (BuildConfig.DEBUG ? note : note.getTitle())); - try { - Note remoteNote; - switch (note.getStatus()) { - case LOCAL_EDITED -> { - Log.v(TAG, " ...create/edit"); - if (note.getRemoteId() != null) { - Log.v(TAG, " ...Note has remoteId → try to edit"); - final var editResponse = notesAPI.editNote(note).execute(); - if (editResponse.isSuccessful()) { - remoteNote = editResponse.body(); - if (remoteNote == null) { - Log.e(TAG, " ...Tried to edit \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); - throw new Exception("Server returned null after editing \"" + note.getTitle() + "\" (#" + note.getId() + ")"); - } - } else if (editResponse.code() == HTTP_NOT_FOUND) { - Log.v(TAG, " ...Note does no longer exist on server → recreate"); - final var createResponse = notesAPI.createNote(note).execute(); - if (createResponse.isSuccessful()) { - remoteNote = createResponse.body(); - if (remoteNote == null) { - Log.e(TAG, " ...Tried to recreate \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); - throw new Exception("Server returned null after recreating \"" + note.getTitle() + "\" (#" + note.getId() + ")"); - } - } else { - throw new Exception(createResponse.message()); - } - } else { - throw FailedSyncResponse.toException(note, editResponse); - } - } else { - Log.v(TAG, " ...Note does not have a remoteId yet → create"); - final var createResponse = notesAPI.createNote(note).execute(); - if (createResponse.isSuccessful()) { - remoteNote = createResponse.body(); - if (remoteNote == null) { - Log.e(TAG, " ...Tried to create \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); - throw new Exception("Server returned null after creating \"" + note.getTitle() + "\" (#" + note.getId() + ")"); - } - repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); - } else { - throw new Exception(createResponse.message()); - } - } - // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); - } - case LOCAL_DELETED -> { - if (note.getRemoteId() == null) { - Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); - } else { - Log.v(TAG, " ...delete (from server and local)"); - final var deleteResponse = notesAPI.deleteNote(note.getRemoteId()).execute(); - if (!deleteResponse.isSuccessful()) { - if (deleteResponse.code() == HTTP_NOT_FOUND) { - Log.v(TAG, " ...delete (note has already been deleted remotely)"); - } else { - throw FailedSyncResponse.toException(note, deleteResponse); - } - } - } - // Please note, that db.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - repo.deleteByNoteId(note.getId(), LOCAL_DELETED); - } - default -> - throw new IllegalStateException("Unknown State of Note " + note + ": " + note.getStatus()); - } - } catch (NextcloudHttpRequestFailedException e) { - if (e.getStatusCode() == HTTP_NOT_MODIFIED) { - Log.d(TAG, "Server returned HTTP Status Code 304 - Not Modified"); - } else { - exceptions.add(e); - success = false; - } - } catch (Exception e) { - if (e instanceof TokenMismatchException) { - apiProvider.invalidateAPICache(ssoAccount); - } - exceptions.add(e); - success = false; - } + success &= pushLocalChange(note); } return success; } + private boolean pushLocalChange(@NonNull Note note) { + try { + switch (note.getStatus()) { + case LOCAL_EDITED -> pushEditedNote(note); + case LOCAL_DELETED -> pushDeletedNote(note); + default -> + throw new IllegalStateException("Unknown State of Note " + note + ": " + note.getStatus()); + } + return true; + } catch (NextcloudHttpRequestFailedException e) { + if (e.getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code 304 - Not Modified"); + return true; + } + return recordFailure(e); + } catch (Exception e) { + if (e instanceof TokenMismatchException) { + apiProvider.invalidateAPICache(ssoAccount); + } + return recordFailure(e); + } + } + + private void pushEditedNote(@NonNull Note note) throws Exception { + Log.v(TAG, " ...create/edit"); + final Note remoteNote = note.getRemoteId() == null ? createRemoteNote(note) : editRemoteNote(note); + // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); + } + + @NonNull + private Note editRemoteNote(@NonNull Note note) throws Exception { + Log.v(TAG, " ...Note has remoteId → try to edit"); + final var editResponse = notesAPI.editNote(note).execute(); + if (editResponse.isSuccessful()) { + return requireRemoteNote(note, editResponse, "editing"); + } + if (editResponse.code() != HTTP_NOT_FOUND) { + throw new NoteSyncException(note.getTitle(), editResponse); + } + Log.v(TAG, " ...Note does no longer exist on server → recreate"); + return postNote(note, "recreating"); + } + + @NonNull + private Note createRemoteNote(@NonNull Note note) throws Exception { + Log.v(TAG, " ...Note does not have a remoteId yet → create"); + final var remoteNote = postNote(note, "creating"); + repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + return remoteNote; + } + + @NonNull + private Note postNote(@NonNull Note note, @NonNull String action) throws Exception { + final var createResponse = notesAPI.createNote(note).execute(); + if (!createResponse.isSuccessful()) { + throw new NoteSyncException(note.getTitle(), createResponse); + } + return requireRemoteNote(note, createResponse, action); + } + + @NonNull + private Note requireRemoteNote(@NonNull Note note, @NonNull Response response, @NonNull String action) throws Exception { + final var remoteNote = response.body(); + if (remoteNote == null) { + final String message = "Server returned null after " + action + " \"" + note.getTitle() + "\" (#" + note.getId() + ")"; + Log.e(TAG, " ..." + message); + throw new Exception(message); + } + return remoteNote; + } + + private void pushDeletedNote(@NonNull Note note) throws Exception { + deleteRemoteNote(note); + // Please note, that db.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + repo.deleteByNoteId(note.getId(), LOCAL_DELETED); + } + + private void deleteRemoteNote(@NonNull Note note) throws Exception { + final Long remoteId = note.getRemoteId(); + if (remoteId == null) { + Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); + return; + } + + Log.v(TAG, " ...delete (from server and local)"); + final var deleteResponse = notesAPI.deleteNote(remoteId).execute(); + if (deleteResponse.isSuccessful()) { + return; + } + if (deleteResponse.code() != HTTP_NOT_FOUND) { + throw new NoteSyncException(note.getTitle(), deleteResponse); + } + Log.v(TAG, " ...delete (note has already been deleted remotely)"); + } + /** * Pull remote Changes: update or create each remote note (if local pendant has no changes) and remove remotely deleted notes. */ @@ -228,79 +237,52 @@ private boolean pullRemoteChanges() { } final var fetchResponse = notesAPI.getNotes(modified, localAccount.getETag()).blockingSingle(); - final var remoteNotes = fetchResponse.getResponse(); - final var remoteIDs = new HashSet(); - // pull remote changes: update or create each remote note - for (final var remoteNote : remoteNotes) { - Log.v(TAG, " Process Remote Note: " + (BuildConfig.DEBUG ? remoteNote : remoteNote.getTitle())); - remoteIDs.add(remoteNote.getRemoteId()); - if (remoteNote.getModified() == null) { - Log.v(TAG, " ... unchanged"); - } else if (idMap.containsKey(remoteNote.getRemoteId())) { - Log.v(TAG, " ... found → Update"); - final Long localId = idMap.get(remoteNote.getRemoteId()); - if (localId != null) { - repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( - localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle())); - } else { - Log.e(TAG, "Tried to update note from server, but local id of note is null. " + (BuildConfig.DEBUG ? remoteNote : remoteNote.getTitle())); - } - } else { - Log.v(TAG, " ... create"); - repo.addNote(localAccount.getId(), remoteNote); - } - } - Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)"); - // remove remotely deleted notes (only those without local changes) - for (final var entry : idMap.entrySet()) { - if (!remoteIDs.contains(entry.getKey())) { - Log.v(TAG, " ... remove " + entry.getValue()); - repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); - } - } - - // update ETag and Last-Modified in order to reduce size of next response - localAccount.setETag(fetchResponse.getHeaders().get(HEADER_KEY_ETAG)); - - final var lastModified = Calendar.getInstance(); - lastModified.setTimeInMillis(0); - final String lastModifiedHeader = fetchResponse.getHeaders().get(HEADER_KEY_LAST_MODIFIED); - if (lastModifiedHeader != null) - lastModified.setTimeInMillis(Date.parse(lastModifiedHeader)); - Log.d(TAG, "ETag: " + fetchResponse.getHeaders().get(HEADER_KEY_ETAG) + "; Last-Modified: " + lastModified + " (" + lastModified + ")"); - - localAccount.setModified(lastModified); + new RemoteNotesMerger(repo, localAccount).merge(fetchResponse, idMap); + return true; + } catch (Throwable t) { + return handlePullFailure(t); + } + } - repo.updateETag(localAccount.getId(), localAccount.getETag()); - repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); + private boolean handlePullFailure(@NonNull Throwable t) { + final Throwable cause = t.getCause(); + if (t.getClass() != RuntimeException.class || cause == null) { + return recordFailure(t); + } + if (isIgnoredPullFailure(cause)) { + return true; + } + if (cause instanceof NextcloudApiNotRespondingException) { + apiProvider.invalidateAPICache(ssoAccount); + } + return recordFailure(t); + } - final String newApiVersion = ApiVersionUtil.sanitize(fetchResponse.getHeaders().get(HEADER_KEY_X_NOTES_API_VERSIONS)); - localAccount.setApiVersion(newApiVersion); - repo.updateApiVersion(localAccount.getId(), newApiVersion); - Log.d(TAG, "ApiVersion: " + newApiVersion); + private boolean isIgnoredPullFailure(@NonNull Throwable cause) { + if (cause instanceof NextcloudHttpRequestFailedException httpException) { + return isIgnoredPullStatus(httpException.getStatusCode()); + } + if (cause.getClass() == NextcloudNetworkException.class) { + Log.w(TAG, "Network connectivity issue during sync"); return true; - } catch (Throwable t) { - final Throwable cause = t.getCause(); - if (t.getClass() == RuntimeException.class && cause != null) { - if (cause.getClass() == NextcloudHttpRequestFailedException.class || cause instanceof NextcloudHttpRequestFailedException) { - final NextcloudHttpRequestFailedException httpException = (NextcloudHttpRequestFailedException) cause; - if (httpException.getStatusCode() == HTTP_NOT_MODIFIED) { - Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Notes not modified."); - return true; - } else if (httpException.getStatusCode() == HTTP_UNAVAILABLE) { - Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Server is in maintenance mode."); - return true; - } - } else if (cause.getClass() == NextcloudApiNotRespondingException.class || cause instanceof NextcloudApiNotRespondingException) { - apiProvider.invalidateAPICache(ssoAccount); - } else if (cause.getClass() == NextcloudNetworkException.class) { - Log.w(TAG, "Network connectivity issue during sync"); - return true; - } - } + } + return false; + } - exceptions.add(t); - return false; + private boolean isIgnoredPullStatus(int statusCode) { + if (statusCode == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code " + statusCode + " - Notes not modified."); + return true; } + if (statusCode == HTTP_UNAVAILABLE) { + Log.d(TAG, "Server returned HTTP Status Code " + statusCode + " - Server is in maintenance mode."); + return true; + } + return false; + } + + private boolean recordFailure(@NonNull Throwable t) { + exceptions.add(t); + return false; } } From f78014525012d26fe611c6cf95c90578968c25ea Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Oct 2026 09:40:16 +0200 Subject: [PATCH 4/7] fix the recreate remoteId bug Signed-off-by: alperozturk96 --- .../owncloud/notes/persistence/NotesServerSyncTask.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index c3ef83ec1..2ded8321d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -165,9 +165,7 @@ private Note editRemoteNote(@NonNull Note note) throws Exception { @NonNull private Note createRemoteNote(@NonNull Note note) throws Exception { Log.v(TAG, " ...Note does not have a remoteId yet → create"); - final var remoteNote = postNote(note, "creating"); - repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); - return remoteNote; + return postNote(note, "creating"); } @NonNull @@ -176,7 +174,9 @@ private Note postNote(@NonNull Note note, @NonNull String action) throws Excepti if (!createResponse.isSuccessful()) { throw new NoteSyncException(note.getTitle(), createResponse); } - return requireRemoteNote(note, createResponse, action); + final var remoteNote = requireRemoteNote(note, createResponse, action); + repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + return remoteNote; } @NonNull From b30f595a753af5bd3ec87d2a32338cff3f382a3f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Oct 2026 13:37:12 +0200 Subject: [PATCH 5/7] better message for HTTP_FORBIDDEN 403 Signed-off-by: alperozturk96 --- .../owncloud/notes/persistence/SyncFailureReason.kt | 4 ++-- app/src/main/res/values/strings.xml | 2 +- gradle.properties | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt index ca37806a5..85e27a083 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt @@ -16,7 +16,7 @@ import java.net.HttpURLConnection.HTTP_UNAVAILABLE enum class SyncFailureReason(@StringRes val message: Int) { UNAUTHORIZED(R.string.error_sync_unauthorized), - READ_ONLY(R.string.error_sync_read_only), + PERMISSION(R.string.error_sync_permission), NOTES_APP_UNAVAILABLE(R.string.error_sync_notes_app_unavailable), LOCKED(R.string.error_sync_locked), SERVER_ERROR(R.string.error_sync_server_error), @@ -32,7 +32,7 @@ enum class SyncFailureReason(@StringRes val message: Int) { fun fromStatusCode(statusCode: Int): SyncFailureReason = when (statusCode) { HTTP_UNAUTHORIZED -> UNAUTHORIZED - HTTP_FORBIDDEN -> READ_ONLY + HTTP_FORBIDDEN -> PERMISSION HTTP_NOT_FOUND -> NOTES_APP_UNAVAILABLE HTTP_LOCKED -> LOCKED HTTP_INTERNAL_ERROR -> SERVER_ERROR diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a48ecf8f0..ca0655f9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,7 +197,7 @@ Synchronization failed A note is open in another app. Close it there to sync your changes. Your login is no longer valid. Log in again in the Nextcloud Files app. - A note is read-only on the server. Your changes could not be saved. + You don’t have permission to save this note. Please check your access and try again. The Notes app is not available on your server. The server could not save your changes. Ask your administrator to check the logs. The server is in maintenance mode. Your changes will sync later. diff --git a/gradle.properties b/gradle.properties index 4619f216c..da978d4cb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,6 @@ android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From 4bbfc94d8c23f34aef6150c64fb2739654c2e71c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Oct 2026 11:22:08 +0200 Subject: [PATCH 6/7] add token mismatch Signed-off-by: alperozturk96 --- .../owncloud/notes/main/SyncErrorMessage.kt | 2 + .../persistence/NotesServerSyncTask.java | 63 +++++++++++-------- .../notes/persistence/RemoteNotesMerger.kt | 21 +++---- .../notes/persistence/SyncFailureReason.kt | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 49 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt index 8cf93ff6a..3585d4b15 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt @@ -10,6 +10,7 @@ import androidx.annotation.StringRes import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException import com.nextcloud.android.sso.exceptions.NextcloudNetworkException +import com.nextcloud.android.sso.exceptions.TokenMismatchException import it.niedermann.owncloud.notes.persistence.NoteSyncException import it.niedermann.owncloud.notes.persistence.SyncFailureReason @@ -31,6 +32,7 @@ object SyncErrorMessage { is NoteSyncException -> reason is NextcloudHttpRequestFailedException -> SyncFailureReason.fromStatusCode(statusCode) is NextcloudNetworkException, is NextcloudApiNotRespondingException -> SyncFailureReason.SERVER_UNREACHABLE + is TokenMismatchException -> SyncFailureReason.TOKEN_MISMATCH else -> null } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index 2ded8321d..b30776d80 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -32,7 +32,6 @@ import java.util.List; import java.util.Map; -import it.niedermann.owncloud.notes.BuildConfig; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; @@ -87,7 +86,8 @@ public void run() { notesAPI = apiProvider.getNotesAPI(context, ssoAccount, ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion())); - Log.i(TAG, "STARTING SYNCHRONIZATION"); + final String accountName = localAccount.getAccountName(); + Log.i(TAG, "Sync started for " + accountName + (onlyLocalChanges ? " (push only)" : " (push and pull)")); final var status = new SyncResultStatus(); status.pushSuccessful = pushLocalChanges(); @@ -95,7 +95,8 @@ public void run() { status.pullSuccessful = pullRemoteChanges(); } - Log.i(TAG, "SYNCHRONIZATION FINISHED"); + Log.i(TAG, "Sync finished for " + accountName + ": push " + (status.pushSuccessful ? "OK" : "FAILED") + + ", pull " + (status.pullSuccessful ? "OK" : "FAILED") + ", errors " + exceptions.size()); onPostExecute(status); } @@ -108,17 +109,19 @@ public void run() { * Push local changes: for each locally created/edited/deleted Note, use NotesClient in order to push the changed to the server. */ private boolean pushLocalChanges() { - Log.d(TAG, "pushLocalChanges()"); + final var localChanges = repo.getLocalModifiedNotes(localAccount.getId()); + Log.d(TAG, "Push: " + localChanges.size() + " local change(s)"); boolean success = true; - for (final var note : repo.getLocalModifiedNotes(localAccount.getId())) { - Log.d(TAG, " Process Local Note: " + (BuildConfig.DEBUG ? note : note.getTitle())); + for (final var note : localChanges) { success &= pushLocalChange(note); } return success; } private boolean pushLocalChange(@NonNull Note note) { + final String step = "Push " + describe(note); + Log.d(TAG, step + ": " + note.getStatus()); try { switch (note.getStatus()) { case LOCAL_EDITED -> pushEditedNote(note); @@ -129,20 +132,19 @@ private boolean pushLocalChange(@NonNull Note note) { return true; } catch (NextcloudHttpRequestFailedException e) { if (e.getStatusCode() == HTTP_NOT_MODIFIED) { - Log.d(TAG, "Server returned HTTP Status Code 304 - Not Modified"); + Log.d(TAG, " → not modified on server (304)"); return true; } - return recordFailure(e); + return recordFailure(step, e); } catch (Exception e) { if (e instanceof TokenMismatchException) { apiProvider.invalidateAPICache(ssoAccount); } - return recordFailure(e); + return recordFailure(step, e); } } private void pushEditedNote(@NonNull Note note) throws Exception { - Log.v(TAG, " ...create/edit"); final Note remoteNote = note.getRemoteId() == null ? createRemoteNote(note) : editRemoteNote(note); // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); @@ -150,7 +152,7 @@ private void pushEditedNote(@NonNull Note note) throws Exception { @NonNull private Note editRemoteNote(@NonNull Note note) throws Exception { - Log.v(TAG, " ...Note has remoteId → try to edit"); + Log.d(TAG, " → editing remote note " + note.getRemoteId()); final var editResponse = notesAPI.editNote(note).execute(); if (editResponse.isSuccessful()) { return requireRemoteNote(note, editResponse, "editing"); @@ -158,13 +160,13 @@ private Note editRemoteNote(@NonNull Note note) throws Exception { if (editResponse.code() != HTTP_NOT_FOUND) { throw new NoteSyncException(note.getTitle(), editResponse); } - Log.v(TAG, " ...Note does no longer exist on server → recreate"); + Log.d(TAG, " → remote note " + note.getRemoteId() + " no longer exists, recreating it"); return postNote(note, "recreating"); } @NonNull private Note createRemoteNote(@NonNull Note note) throws Exception { - Log.v(TAG, " ...Note does not have a remoteId yet → create"); + Log.d(TAG, " → not on server yet, creating it"); return postNote(note, "creating"); } @@ -176,6 +178,7 @@ private Note postNote(@NonNull Note note, @NonNull String action) throws Excepti } final var remoteNote = requireRemoteNote(note, createResponse, action); repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + Log.d(TAG, " → created remote note " + remoteNote.getRemoteId()); return remoteNote; } @@ -183,9 +186,8 @@ private Note postNote(@NonNull Note note, @NonNull String action) throws Excepti private Note requireRemoteNote(@NonNull Note note, @NonNull Response response, @NonNull String action) throws Exception { final var remoteNote = response.body(); if (remoteNote == null) { - final String message = "Server returned null after " + action + " \"" + note.getTitle() + "\" (#" + note.getId() + ")"; - Log.e(TAG, " ..." + message); - throw new Exception(message); + throw new Exception("Server returned null after " + action + " \"" + note.getTitle() + "\" (#" + + note.getId() + ")"); } return remoteNote; } @@ -199,11 +201,11 @@ private void pushDeletedNote(@NonNull Note note) throws Exception { private void deleteRemoteNote(@NonNull Note note) throws Exception { final Long remoteId = note.getRemoteId(); if (remoteId == null) { - Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); + Log.d(TAG, " → never synced, deleting it only locally"); return; } - Log.v(TAG, " ...delete (from server and local)"); + Log.d(TAG, " → deleting remote note " + remoteId); final var deleteResponse = notesAPI.deleteNote(remoteId).execute(); if (deleteResponse.isSuccessful()) { return; @@ -211,20 +213,20 @@ private void deleteRemoteNote(@NonNull Note note) throws Exception { if (deleteResponse.code() != HTTP_NOT_FOUND) { throw new NoteSyncException(note.getTitle(), deleteResponse); } - Log.v(TAG, " ...delete (note has already been deleted remotely)"); + Log.d(TAG, " → remote note " + remoteId + " was already deleted on server"); } /** * Pull remote Changes: update or create each remote note (if local pendant has no changes) and remove remotely deleted notes. */ private boolean pullRemoteChanges() { - Log.d(TAG, "pullRemoteChanges() for account " + localAccount.getAccountName()); try { final var idMap = repo.getIdMap(localAccount.getId()); // FIXME re-reading the localAccount is only a workaround for a not-up-to-date eTag in localAccount. final var accountFromDatabase = repo.getAccountById(localAccount.getId()); if (accountFromDatabase == null) { + Log.w(TAG, "Pull skipped: account no longer exists"); callbacks.remove(localAccount.getId()); return true; } @@ -232,10 +234,11 @@ private boolean pullRemoteChanges() { localAccount.setETag(accountFromDatabase.getETag()); final var modified = localAccount.getModified(); if (modified == null) { - Log_OC.e(TAG, "modified is null cannot fetch notes"); + Log_OC.e(TAG, "Pull failed: last modified date of the account is unknown"); return false; } + Log.d(TAG, "Pull: fetching changes since " + modified.getTime() + " (ETag " + localAccount.getETag() + ")"); final var fetchResponse = notesAPI.getNotes(modified, localAccount.getETag()).blockingSingle(); new RemoteNotesMerger(repo, localAccount).merge(fetchResponse, idMap); return true; @@ -247,7 +250,7 @@ private boolean pullRemoteChanges() { private boolean handlePullFailure(@NonNull Throwable t) { final Throwable cause = t.getCause(); if (t.getClass() != RuntimeException.class || cause == null) { - return recordFailure(t); + return recordFailure("Pull", t); } if (isIgnoredPullFailure(cause)) { return true; @@ -255,7 +258,7 @@ private boolean handlePullFailure(@NonNull Throwable t) { if (cause instanceof NextcloudApiNotRespondingException) { apiProvider.invalidateAPICache(ssoAccount); } - return recordFailure(t); + return recordFailure("Pull", t); } private boolean isIgnoredPullFailure(@NonNull Throwable cause) { @@ -263,7 +266,7 @@ private boolean isIgnoredPullFailure(@NonNull Throwable cause) { return isIgnoredPullStatus(httpException.getStatusCode()); } if (cause.getClass() == NextcloudNetworkException.class) { - Log.w(TAG, "Network connectivity issue during sync"); + Log.w(TAG, "Pull skipped: no network connection"); return true; } return false; @@ -271,18 +274,24 @@ private boolean isIgnoredPullFailure(@NonNull Throwable cause) { private boolean isIgnoredPullStatus(int statusCode) { if (statusCode == HTTP_NOT_MODIFIED) { - Log.d(TAG, "Server returned HTTP Status Code " + statusCode + " - Notes not modified."); + Log.d(TAG, "Pull: no changes on server (304)"); return true; } if (statusCode == HTTP_UNAVAILABLE) { - Log.d(TAG, "Server returned HTTP Status Code " + statusCode + " - Server is in maintenance mode."); + Log.w(TAG, "Pull skipped: server is in maintenance mode (503)"); return true; } return false; } - private boolean recordFailure(@NonNull Throwable t) { + private boolean recordFailure(@NonNull String step, @NonNull Throwable t) { + Log.w(TAG, step + " failed: " + t.getMessage()); exceptions.add(t); return false; } + + @NonNull + private static String describe(@NonNull Note note) { + return "note #" + note.getId() + " \"" + note.getTitle() + "\" (remote " + note.getRemoteId() + ")"; + } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt index 9cc20286e..18cd4eaaf 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt @@ -8,7 +8,6 @@ package it.niedermann.owncloud.notes.persistence import android.util.Log import com.nextcloud.android.sso.api.ParsedResponse -import it.niedermann.owncloud.notes.BuildConfig import it.niedermann.owncloud.notes.persistence.entity.Account import it.niedermann.owncloud.notes.persistence.entity.Note import it.niedermann.owncloud.notes.shared.model.DBStatus @@ -20,13 +19,14 @@ import java.util.Date class RemoteNotesMerger(private val repo: NotesRepository, private val localAccount: Account) { companion object { - private val TAG = RemoteNotesMerger::class.java.simpleName + private val TAG = NotesServerSyncTask::class.java.simpleName private const val HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions" private const val HEADER_KEY_ETAG = "ETag" private const val HEADER_KEY_LAST_MODIFIED = "Last-Modified" } fun merge(fetchResponse: ParsedResponse>, idMap: Map) { + Log.d(TAG, "Pull: received ${fetchResponse.response.size} remote note(s)") val remoteIds = applyRemoteNotes(fetchResponse.response, idMap) removeRemotelyDeletedNotes(idMap, remoteIds) updateSyncState(fetchResponse.headers) @@ -35,7 +35,6 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco private fun applyRemoteNotes(remoteNotes: List, idMap: Map): Set { val remoteIds = HashSet() for (remoteNote in remoteNotes) { - Log.v(TAG, " Process Remote Note: " + if (BuildConfig.DEBUG) remoteNote else remoteNote.title) remoteIds.add(remoteNote.remoteId) applyRemoteNote(remoteNote, idMap) } @@ -43,24 +42,24 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco } private fun applyRemoteNote(remoteNote: Note, idMap: Map) { + val label = "remote note ${remoteNote.remoteId} \"${remoteNote.title}\"" val modified = remoteNote.modified if (modified == null) { - Log.v(TAG, " ... unchanged") + Log.v(TAG, " → $label unchanged") return } if (!idMap.containsKey(remoteNote.remoteId)) { - Log.v(TAG, " ... create") + Log.d(TAG, " → $label is new, creating local note") repo.addNote(localAccount.id, remoteNote) return } - Log.v(TAG, " ... found → Update") val localId = idMap[remoteNote.remoteId] if (localId == null) { - val note = if (BuildConfig.DEBUG) remoteNote else remoteNote.title - Log.e(TAG, "Tried to update note from server, but local id of note is null. $note") + Log.e(TAG, " → $label has no local id, cannot update it") return } + Log.d(TAG, " → $label changed, updating local note #$localId") repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( localId, modified.timeInMillis, @@ -74,10 +73,9 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco } private fun removeRemotelyDeletedNotes(idMap: Map, remoteIds: Set) { - Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)") for ((remoteId, localId) in idMap) { if (!remoteIds.contains(remoteId)) { - Log.v(TAG, " ... remove $localId") + Log.d(TAG, " → remote note $remoteId was deleted on server, removing local note #$localId") repo.deleteByNoteId(localId, DBStatus.VOID) } } @@ -87,7 +85,6 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco localAccount.eTag = headers[HEADER_KEY_ETAG] val lastModified = parseLastModified(headers[HEADER_KEY_LAST_MODIFIED]) - Log.d(TAG, "ETag: ${headers[HEADER_KEY_ETAG]}; Last-Modified: $lastModified ($lastModified)") localAccount.modified = lastModified repo.updateETag(localAccount.id, localAccount.eTag) @@ -96,7 +93,7 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco val newApiVersion = ApiVersionUtil.sanitize(headers[HEADER_KEY_X_NOTES_API_VERSIONS]) localAccount.apiVersion = newApiVersion repo.updateApiVersion(localAccount.id, newApiVersion) - Log.d(TAG, "ApiVersion: $newApiVersion") + Log.d(TAG, "Pull: saved ETag ${localAccount.eTag}, last modified ${lastModified.time}, API $newApiVersion") } @Suppress("DEPRECATION") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt index 85e27a083..34dbed04d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt @@ -23,6 +23,7 @@ enum class SyncFailureReason(@StringRes val message: Int) { MAINTENANCE(R.string.error_sync_maintenance), INSUFFICIENT_STORAGE(R.string.error_sync_insufficient_storage), SERVER_UNREACHABLE(R.string.error_sync_server_unreachable), + TOKEN_MISMATCH(R.string.error_sync_token_mismatch), UNKNOWN(R.string.error_synchronization); companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca0655f9b..9dd008cac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,7 @@ The server is in maintenance mode. Your changes will sync later. Your Nextcloud storage is full. Free up space to sync your changes. Could not reach your server. Check your connection and the Nextcloud Files app. + Notes lost access to your Files app account. Remove and add the account again. No network connection Server is in maintenance mode An unknown error has occurred. From 6b0ae68b159d0e566b4bdb44d53059438d50c375 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Oct 2026 11:32:41 +0200 Subject: [PATCH 7/7] improve sync active state Signed-off-by: alperozturk96 --- .../owncloud/notes/persistence/NotesRepository.java | 1 + .../owncloud/notes/persistence/NotesServerSyncTask.java | 3 ++- .../owncloud/notes/persistence/RemoteNotesMerger.kt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java index f6a1d6e7c..1aae07612 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -250,6 +250,7 @@ public LiveData addAccount(@NonNull String url, @NonNull String us return importTask.importNotes(new IResponseCallback<>() { @Override public void onSuccess(Void result) { + syncActive.put(account.getId(), false); callback.onSuccess(account); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index b30776d80..3a8d48fd0 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -96,7 +96,8 @@ public void run() { } Log.i(TAG, "Sync finished for " + accountName + ": push " + (status.pushSuccessful ? "OK" : "FAILED") - + ", pull " + (status.pullSuccessful ? "OK" : "FAILED") + ", errors " + exceptions.size()); + + ", pull " + (onlyLocalChanges ? "skipped" : status.pullSuccessful ? "OK" : "FAILED") + + ", errors " + exceptions.size()); onPostExecute(status); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt index 18cd4eaaf..6642b8a7b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt @@ -42,12 +42,12 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco } private fun applyRemoteNote(remoteNote: Note, idMap: Map) { - val label = "remote note ${remoteNote.remoteId} \"${remoteNote.title}\"" val modified = remoteNote.modified if (modified == null) { - Log.v(TAG, " → $label unchanged") + Log.v(TAG, " → remote note ${remoteNote.remoteId} unchanged") return } + val label = "remote note ${remoteNote.remoteId} \"${remoteNote.title}\"" if (!idMap.containsKey(remoteNote.remoteId)) { Log.d(TAG, " → $label is new, creating local note") repo.addNote(localAccount.id, remoteNote)