Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@

import it.niedermann.owncloud.notes.BuildConfig;
import it.niedermann.owncloud.notes.R;
import it.niedermann.owncloud.notes.persistence.NoteSyncException;
import it.niedermann.owncloud.notes.persistence.SyncFailureReason;

public class TipsAdapter extends RecyclerView.Adapter<TipsViewHolder> {

Expand Down Expand Up @@ -89,6 +91,9 @@ public void setThrowables(@NonNull List<Throwable> throwables) {
add(R.string.error_dialog_timeout_toggle, new Intent(Settings.ACTION_WIFI_SETTINGS).putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_open_network));
} else if (throwable instanceof JSONException || throwable instanceof NullPointerException) {
add(R.string.error_dialog_check_server);
} else if (throwable instanceof NoteSyncException syncException && syncException.getReason() == SyncFailureReason.LOCKED) {
add(R.string.error_dialog_tip_note_locked_close);
add(R.string.error_dialog_tip_note_locked_unlock);
} else if (throwable instanceof NextcloudHttpRequestFailedException) {
final int statusCode = ((NextcloudHttpRequestFailedException) throwable).getStatusCode();
switch (statusCode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,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, SyncErrorMessage.of(exceptions), Snackbar.LENGTH_LONG)
BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(MainActivity.this, exceptions), Snackbar.LENGTH_LONG)
.setAnchorView(binding.activityNotesListView.fabCreate)
.setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(exceptions)
.show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()))
Expand Down Expand Up @@ -325,7 +325,7 @@ public void onError(@NonNull Throwable t) {
.setAnchorView(binding.activityNotesListView.fabCreate)
.show();
} else {
BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(t), Snackbar.LENGTH_LONG)
BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(MainActivity.this, t), Snackbar.LENGTH_LONG)
.setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t)
.show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()))
.setAnchorView(binding.activityNotesListView.fabCreate)
Expand Down Expand Up @@ -609,7 +609,7 @@ public void onError(@NonNull Throwable t) {
.setAnchorView(binding.activityNotesListView.fabCreate)
.show();
} else {
BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(t), Snackbar.LENGTH_LONG)
BrandedSnackbar.make(coordinatorLayout, SyncErrorMessage.of(MainActivity.this, t), Snackbar.LENGTH_LONG)
.setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t)
.show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()))
.setAnchorView(binding.activityNotesListView.fabCreate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,48 @@
*/
package it.niedermann.owncloud.notes.main

import androidx.annotation.StringRes
import android.content.Context
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.R
import it.niedermann.owncloud.notes.persistence.NoteSyncException
import it.niedermann.owncloud.notes.persistence.SyncFailureReason

object SyncErrorMessage {
@JvmStatic
@StringRes
fun of(exceptions: Collection<Throwable>): Int =
(exceptions.map { it.syncFailureReason() }.distinct().singleOrNull() ?: SyncFailureReason.UNKNOWN).message
fun of(context: Context, exceptions: Collection<Throwable>): String {
val reasons = exceptions.map { it.syncFailureReason() }.distinct()
val reason = reasons.singleOrNull() ?: SyncFailureReason.UNKNOWN
if (reason != SyncFailureReason.LOCKED) {
return context.getString(reason.message)
}
return lockedMessage(context, exceptions.mapNotNull { it.lockedNoteTitle() }.distinct())
}

@JvmStatic
@StringRes
fun of(exception: Throwable): Int = exception.syncFailureReason().message
fun of(context: Context, exception: Throwable): String = of(context, listOf(exception))

private fun lockedMessage(context: Context, lockedNoteTitles: List<String>): String = when (lockedNoteTitles.size) {
0 -> context.getString(SyncFailureReason.LOCKED.message)
1 -> context.getString(R.string.error_sync_note_locked, lockedNoteTitles.single())
else -> context.resources.getQuantityString(
R.plurals.error_sync_notes_locked,
lockedNoteTitles.size,
lockedNoteTitles.size,
)
}

private fun Throwable.lockedNoteTitle(): String? = causeChain()
.filterIsInstance<NoteSyncException>()
.firstOrNull { it.reason == SyncFailureReason.LOCKED }
?.noteTitle

private fun Throwable.syncFailureReason(): SyncFailureReason =
generateSequence(this) { it.cause }.firstNotNullOfOrNull { it.directSyncFailureReason() }
?: SyncFailureReason.UNKNOWN
causeChain().firstNotNullOfOrNull { it.directSyncFailureReason() } ?: SyncFailureReason.UNKNOWN

private fun Throwable.causeChain(): Sequence<Throwable> = generateSequence(this) { it.cause }

private fun Throwable.directSyncFailureReason(): SyncFailureReason? = when (this) {
is NoteSyncException -> reason
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package it.niedermann.owncloud.notes.persistence

import retrofit2.Response

class NoteSyncException(noteTitle: String, response: Response<*>) :
class NoteSyncException(val noteTitle: String, response: Response<*>) :
Exception("Could not sync \"$noteTitle\" (HTTP ${response.code()}): ${response.message()}") {
val reason = SyncFailureReason.fromStatusCode(response.code())
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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),
LOCKED(R.string.error_sync_unknown_note_locked),
SERVER_ERROR(R.string.error_sync_server_error),
MAINTENANCE(R.string.error_sync_maintenance),
INSUFFICIENT_STORAGE(R.string.error_sync_insufficient_storage),
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@

<string name="error_sync">Synchronization failed: %1$s</string>
<string name="error_synchronization">Synchronization failed</string>
<string name="error_sync_locked">A note is open in another app. Close it there to sync your changes.</string>
<string name="error_sync_unknown_note_locked">A note is locked on the server. Close it in the other app, or unlock it in the web interface.</string>
<string name="error_sync_note_locked">“%1$s” is locked on the server. Close it in the other app, or unlock it in the web interface.</string>
<plurals name="error_sync_notes_locked">
<item quantity="one">%d note is locked on the server. Close it in the other app, or unlock it in the web interface.</item>
<item quantity="other">%d notes are locked on the server. Close them in the other app, or unlock them in the web interface.</item>
</plurals>
<string name="error_sync_unauthorized">Your login is no longer valid. Log in again in the Nextcloud Files app.</string>
<string name="error_sync_permission">You don’t have permission to save this note. Please check your access and try again.</string>
<string name="error_sync_notes_app_unavailable">The Notes app is not available on your server.</string>
Expand Down Expand Up @@ -354,6 +359,8 @@
<string name="error_dialog_redirect">Your server did respond with a HTTP 302 status code, which implies, that you do not have installed the Notes app on your server or something is misconfigured. This can be caused by custom overrides in a .htaccess-file or by Nextcloud apps like OID Client.</string>
<string name="error_dialog_tip_disable_battery_optimizations">Please disable all battery optimizations for Nextcloud and the Notes app.</string>
<string name="error_dialog_min_version">The Notes Android app requires the Nextcloud Android app to be at least version 3.18.</string>
<string name="error_dialog_tip_note_locked_close">The note is locked on the server. This usually means it is open in the Text editor or in another app. Close it there and sync again.</string>
<string name="error_dialog_tip_note_locked_unlock">If the note is not open anywhere, the lock is stale. In the Nextcloud web interface, open Files, find the note in the Notes folder, open its menu and choose Unlock. If that is not offered, ask your administrator to remove the lock.</string>
<string name="added_content">Added "%1$s"</string>
<string name="shared_text_empty">Shared text was empty</string>
<string name="append_to_note">Append to note</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package it.niedermann.owncloud.notes.main

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import it.niedermann.owncloud.notes.R
import it.niedermann.owncloud.notes.persistence.NoteSyncException
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import retrofit2.Response

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class SyncErrorMessageTest {

private val context: Context = ApplicationProvider.getApplicationContext()

@Test
fun singleLockedNoteIsNamed() {
val message = SyncErrorMessage.of(context, listOf(noteSyncException(GROCERIES, HTTP_LOCKED)))

assertEquals(context.getString(R.string.error_sync_note_locked, GROCERIES), message)
}

@Test
fun severalLockedNotesAreCounted() {
val exceptions = listOf(
noteSyncException(GROCERIES, HTTP_LOCKED),
noteSyncException(INVOICES, HTTP_LOCKED),
)

val message = SyncErrorMessage.of(context, exceptions)

assertEquals(context.resources.getQuantityString(R.plurals.error_sync_notes_locked, 2, 2), message)
}

@Test
fun lockedNoteIsFoundInCauseChain() {
val wrapped = RuntimeException(IllegalStateException(noteSyncException(GROCERIES, HTTP_LOCKED)))

val message = SyncErrorMessage.of(context, wrapped)

assertEquals(context.getString(R.string.error_sync_note_locked, GROCERIES), message)
}

@Test
fun lockedTogetherWithOtherFailuresFallsBackToGenericMessage() {
val exceptions = listOf(
noteSyncException(GROCERIES, HTTP_LOCKED),
noteSyncException(INVOICES, HTTP_INSUFFICIENT_STORAGE),
)

val message = SyncErrorMessage.of(context, exceptions)

assertEquals(context.getString(R.string.error_synchronization), message)
}

@Test
fun otherFailureReasonsKeepTheirMessage() {
val message = SyncErrorMessage.of(context, noteSyncException(GROCERIES, HTTP_INSUFFICIENT_STORAGE))

assertEquals(context.getString(R.string.error_sync_insufficient_storage), message)
}

private fun noteSyncException(title: String, statusCode: Int) =
NoteSyncException(title, Response.error<Any>(statusCode, "".toResponseBody()))

companion object {
private const val GROCERIES = "Groceries"
private const val INVOICES = "Invoices"
private const val HTTP_LOCKED = 423
private const val HTTP_INSUFFICIENT_STORAGE = 507
}
}