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..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 @@ -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())) @@ -324,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) @@ -608,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/main/SyncErrorMessage.kt b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt new file mode 100644 index 000000000..3585d4b15 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/SyncErrorMessage.kt @@ -0,0 +1,38 @@ +/* + * 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 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 + +object SyncErrorMessage { + @JvmStatic + @StringRes + 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 + is TokenMismatchException -> SyncFailureReason.TOKEN_MISMATCH + 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/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 03d151a77..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 @@ -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; @@ -27,21 +28,17 @@ 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; -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; -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; /** @@ -52,10 +49,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; @@ -93,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(); @@ -101,7 +95,9 @@ public void run() { status.pullSuccessful = pullRemoteChanges(); } - Log.i(TAG, "SYNCHRONIZATION FINISHED"); + Log.i(TAG, "Sync finished for " + accountName + ": push " + (status.pushSuccessful ? "OK" : "FAILED") + + ", pull " + (onlyLocalChanges ? "skipped" : status.pullSuccessful ? "OK" : "FAILED") + + ", errors " + exceptions.size()); onPostExecute(status); } @@ -114,107 +110,124 @@ 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; - final var notes = repo.getLocalModifiedNotes(localAccount.getId()); - for (Note note : notes) { - 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 new Exception(editResponse.message()); - } - } 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 new Exception(deleteResponse.message()); - } - } - } - // 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; - } + 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); + 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, " → not modified on server (304)"); + return true; + } + return recordFailure(step, e); + } catch (Exception e) { + if (e instanceof TokenMismatchException) { + apiProvider.invalidateAPICache(ssoAccount); + } + return recordFailure(step, e); + } + } + + private void pushEditedNote(@NonNull Note note) throws Exception { + 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.d(TAG, " → editing remote note " + note.getRemoteId()); + 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.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.d(TAG, " → not on server yet, creating it"); + return postNote(note, "creating"); + } + + @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); + } + final var remoteNote = requireRemoteNote(note, createResponse, action); + repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + Log.d(TAG, " → created remote note " + remoteNote.getRemoteId()); + return remoteNote; + } + + @NonNull + private Note requireRemoteNote(@NonNull Note note, @NonNull Response response, @NonNull String action) throws Exception { + final var remoteNote = response.body(); + if (remoteNote == null) { + throw new Exception("Server returned null after " + action + " \"" + note.getTitle() + "\" (#" + + note.getId() + ")"); + } + 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.d(TAG, " → never synced, deleting it only locally"); + return; + } + + Log.d(TAG, " → deleting remote note " + remoteId); + final var deleteResponse = notesAPI.deleteNote(remoteId).execute(); + if (deleteResponse.isSuccessful()) { + return; + } + if (deleteResponse.code() != HTTP_NOT_FOUND) { + throw new NoteSyncException(note.getTitle(), deleteResponse); + } + 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; } @@ -222,84 +235,64 @@ 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(); - 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("Pull", t); + } + if (isIgnoredPullFailure(cause)) { + return true; + } + if (cause instanceof NextcloudApiNotRespondingException) { + apiProvider.invalidateAPICache(ssoAccount); + } + return recordFailure("Pull", 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, "Pull skipped: no network connection"); 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, "Pull: no changes on server (304)"); + return true; + } + if (statusCode == HTTP_UNAVAILABLE) { + Log.w(TAG, "Pull skipped: server is in maintenance mode (503)"); + return true; } + return false; + } + + 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 new file mode 100644 index 000000000..6642b8a7b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt @@ -0,0 +1,106 @@ +/* + * 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.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 = 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) + } + + private fun applyRemoteNotes(remoteNotes: List, idMap: Map): Set { + val remoteIds = HashSet() + for (remoteNote in remoteNotes) { + 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, " → 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) + return + } + + val localId = idMap[remoteNote.remoteId] + if (localId == null) { + 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, + remoteNote.title, + remoteNote.favorite, + remoteNote.category, + remoteNote.eTag, + remoteNote.content, + generateNoteExcerpt(remoteNote.content, remoteNote.title) + ) + } + + private fun removeRemotelyDeletedNotes(idMap: Map, remoteIds: Set) { + for ((remoteId, localId) in idMap) { + if (!remoteIds.contains(remoteId)) { + Log.d(TAG, " → remote note $remoteId was deleted on server, removing local note #$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]) + 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, "Pull: saved ETag ${localAccount.eTag}, last modified ${lastModified.time}, API $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..34dbed04d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncFailureReason.kt @@ -0,0 +1,46 @@ +/* + * 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), + 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), + 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 { + 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 -> PERMISSION + 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 80d4284fc..9dd008cac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,6 +195,15 @@ Synchronization failed: %1$s 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. + 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. + 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. 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