From c02802e06ef95e580d947ec2cef2669402842d7d Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 14:16:25 +0300 Subject: [PATCH] fix(sync): show readable errors and remind to save a tested connection HTTP errors no longer carry the raw JSON body: the first line names the error and the body follows as text. The notification for a persistent problem now says what to change in Settings instead of repeating the server response. Test Connection checks the typed token without saving it, so a passed test followed by Sync now kept failing with the saved token. Settings now asks to save after a successful test of unsaved values. Closes #41 --- README.md | 2 +- .../org/evsyukov/shareding/QueueUiTest.kt | 36 +++++++++++++++++ .../evsyukov/shareding/SyncNotifierTest.kt | 23 ++++++----- .../org/evsyukov/shareding/MainActivity.kt | 10 ++++- .../shareding/ShareDingApplication.kt | 10 +---- .../evsyukov/shareding/network/LinkdingApi.kt | 39 +++++++++++++++++-- .../evsyukov/shareding/sync/SyncNotifier.kt | 26 ++++++------- .../org/evsyukov/shareding/sync/SyncWorker.kt | 2 +- .../shareding/network/ApiErrorsTest.kt | 29 ++++++++++++++ .../shareding/network/LinkdingApiTest.kt | 3 +- docs/specs/share-to-linkding.md | 12 +++--- 11 files changed, 148 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/org/evsyukov/shareding/network/ApiErrorsTest.kt diff --git a/README.md b/README.md index 33a23af..b5c0e3a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Settings also lets you set default tags and choose whether bookmarks are marked ShareDing connects to linkding and bookmarked pages directly. It has no proxy setting; if your server is reachable only through another route, use a VPN on your device. Versions before 0.2.1 had an optional HTTP proxy. It was removed because proxy credentials were sent without TLS, and updating deletes any saved proxy settings. -Settings shows an inline Test Connection result. The API token is encrypted using a key held in Android Keystore. The [privacy policy](docs/privacy.md) explains how the app handles bookmarks and the token. +Settings shows an inline Test Connection result. The test uses the values typed in the fields and does not save them; tap **Save settings** to sync with a connection you have just tested. The API token is encrypted using a key held in Android Keystore. The [privacy policy](docs/privacy.md) explains how the app handles bookmarks and the token. ShareDing is written in Kotlin. Jetpack Compose provides the interface, Room stores the queue, and WorkManager handles background sync. diff --git a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt index 9ca7b56..6629f6d 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt @@ -354,6 +354,42 @@ class QueueUiTest { compose.onNodeWithText("Sync problem alerts").assertDoesNotExist() } + @Test fun testedButUnsavedTokenAsksToSaveSettings() { + val tls = AndroidTestTls(androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().context) + MockWebServer().apply { tls.start(this) }.use { server -> + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = + if (request.getHeader("Authorization") == "Token restored") MockResponse().setBody("{}") + else MockResponse().setResponseCode(401).setBody("""{"detail":"Invalid token."}""") + } + val app = compose.activity.application as ShareDingApplication + app.container.settings.save(tls.url(server), "revoked", "", true, false) + val reminder = "Save settings to sync with this connection." + compose.onNodeWithText("Settings").performClick() + compose.onNodeWithText("Test Connection").performClick() + compose.waitUntil(5_000) { + compose.onAllNodes(hasText("Connection failed: linkding rejected the API token (HTTP 401)\nInvalid token.")) + .fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText(reminder).assertDoesNotExist() + + // Test Connection checks the typed token; sync would still use the saved one. + compose.onNodeWithText("API token (leave empty to keep current)").performTextInput("restored") + compose.onNodeWithText("Test Connection").performClick() + compose.waitUntil(5_000) { + compose.onAllNodes(hasText("Connection successful")).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText(reminder).assertIsDisplayed() + assertEquals("revoked", app.container.settings.token()) + + compose.onNodeWithText("Save settings").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { app.container.settings.token() == "restored" } + compose.waitUntil(5_000) { + compose.onAllNodes(hasText(reminder)).fetchSemanticsNodes().isEmpty() + } + } + } + @Test fun connectionFailureRemainsVisibleInSettings() { compose.onNodeWithText("Settings").performClick() compose.onNodeWithText("Server URL").performTextClearance() diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt index 35dc058..2ef8da9 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt @@ -54,7 +54,7 @@ class SyncNotifierTest { @Test fun problemIsShownOncePerKindAndClearedOnSuccess() { val notifier = SyncNotifier(context) { now } - notifier.problem(SyncProblem.AUTH, "linkding returned HTTP 401") + notifier.problem(SyncProblem.AUTH) val first = active(SyncNotifier.PROBLEM_ID) assertNotNull(first) assertEquals(SyncProblem.AUTH.title, first!!.notification.extras.getString("android.title")) @@ -62,31 +62,31 @@ class SyncNotifierTest { manager.cancel(SyncNotifier.PROBLEM_ID) waitGone(SyncNotifier.PROBLEM_ID) - notifier.problem(SyncProblem.AUTH, "linkding returned HTTP 401") + notifier.problem(SyncProblem.AUTH) Thread.sleep(300) assertNull("Same problem must not be shown again", manager.activeNotifications .firstOrNull { it.id == SyncNotifier.PROBLEM_ID }) - notifier.problem(SyncProblem.NOT_FOUND, "linkding returned HTTP 404") + notifier.problem(SyncProblem.NOT_FOUND) assertEquals(SyncProblem.NOT_FOUND.title, active(SyncNotifier.PROBLEM_ID)!!.notification.extras.getString("android.title")) notifier.resolved() waitGone(SyncNotifier.PROBLEM_ID) assertNull(manager.activeNotifications.firstOrNull { it.id == SyncNotifier.PROBLEM_ID }) - notifier.problem(SyncProblem.NOT_FOUND, "again") + notifier.problem(SyncProblem.NOT_FOUND) assertNotNull("A resolved problem may be shown again", active(SyncNotifier.PROBLEM_ID)) } @Test fun reminderReturnsAfterTheUserDismissedIt() { val notifier = SyncNotifier(context) { now } - notifier.remind(SyncProblem.NOT_CONFIGURED, SyncProblem.NOT_CONFIGURED_DETAIL) + notifier.remind(SyncProblem.NOT_CONFIGURED) assertEquals(SyncProblem.NOT_CONFIGURED.title, active(SyncNotifier.PROBLEM_ID)?.notification?.extras?.getString("android.title")) manager.cancel(SyncNotifier.PROBLEM_ID) waitGone(SyncNotifier.PROBLEM_ID) - notifier.remind(SyncProblem.NOT_CONFIGURED, SyncProblem.NOT_CONFIGURED_DETAIL) + notifier.remind(SyncProblem.NOT_CONFIGURED) assertNotNull("Every save while sync cannot run reminds again", active(SyncNotifier.PROBLEM_ID)) } @@ -122,10 +122,15 @@ class SyncNotifierTest { try { app.container.settings.save(tls.url(server), "revoked", "", true, false) dao.insert(Bookmark(url = "https://example.com/notify")) - server.enqueue(MockResponse().setResponseCode(401).setBody("Invalid token.")) + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"detail":"Invalid token."}""")) TestListenableWorkerBuilder(context).build().doWork() - assertEquals(SyncProblem.AUTH.title, - active(SyncNotifier.PROBLEM_ID)?.notification?.extras?.getString("android.title")) + val shown = active(SyncNotifier.PROBLEM_ID)?.notification?.extras + assertEquals(SyncProblem.AUTH.title, shown?.getString("android.title")) + // The text tells the user what to do; the server response stays on the queue card. + assertEquals("Saved links stay in the queue. Check the API token in Settings.", + shown?.getCharSequence("android.text")?.toString()) + assertEquals("linkding rejected the API token (HTTP 401)\nInvalid token.", + dao.findByUrl("https://example.com/notify")?.lastError) // A rejected token stops sync; the next attempt needs Sync now, Retry, or new settings. app.container.settings.resumeSync() diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index d6d6591..38b7832 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -615,6 +615,8 @@ internal fun TagInput(value: String, onValueChange: (String) -> Unit, label: Str } } +private const val CONNECTION_OK = "Connection successful" + @Composable private fun SettingsScreen(settings: Settings, padding: PaddingValues, availableTags: List, tagLoadError: Boolean, @@ -685,7 +687,7 @@ private fun SettingsScreen(settings: Settings, padding: PaddingValues, } if (testVersion == version) { testFailed = result.isFailure - testStatus = if (result.isSuccess) "Connection successful" + testStatus = if (result.isSuccess) CONNECTION_OK else "Connection failed: ${result.exceptionOrNull()?.message ?: "Unknown error"}" testing = false } @@ -697,6 +699,12 @@ private fun SettingsScreen(settings: Settings, padding: PaddingValues, else MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) } + // The test used the values typed here; sync keeps using the saved ones until Save. + if (testStatus == CONNECTION_OK && !savedConnectionIsCurrent) { + Text("Save settings to sync with this connection.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } item { diff --git a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt index c36d9bb..6f84cb5 100644 --- a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt +++ b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt @@ -78,14 +78,8 @@ class AppContainer(application: Application) { /** After a link is saved: schedules sync, or tells the user why the link will not be sent. */ suspend fun linkSaved(): SyncProblem? { - val current = settings.state.value - val blocked = SyncProblem.blocking(current) - if (blocked == null) { - scheduler.requestSync() - } else { - notifier.remind(blocked, if (blocked == SyncProblem.NOT_CONFIGURED) - SyncProblem.NOT_CONFIGURED_DETAIL else current.lastError) - } + val blocked = SyncProblem.blocking(settings.state.value) + if (blocked == null) scheduler.requestSync() else notifier.remind(blocked) return blocked } diff --git a/app/src/main/java/org/evsyukov/shareding/network/LinkdingApi.kt b/app/src/main/java/org/evsyukov/shareding/network/LinkdingApi.kt index f9a074b..9b2338c 100644 --- a/app/src/main/java/org/evsyukov/shareding/network/LinkdingApi.kt +++ b/app/src/main/java/org/evsyukov/shareding/network/LinkdingApi.kt @@ -2,6 +2,7 @@ package org.evsyukov.shareding.network import android.net.Network import com.google.gson.JsonArray +import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import java.net.URI @@ -86,6 +87,40 @@ object TagNames { class ApiException(val code: Int, message: String) : Exception(message) +/** Turns an HTTP error from linkding into text a person can read. */ +object ApiErrors { + fun message(code: Int, body: String): String { + val reason = when (code) { + 401, 403 -> "linkding rejected the API token (HTTP $code)" + 404 -> "linkding API not found (HTTP 404)" + else -> "linkding returned HTTP $code" + } + val detail = readable(body.trim()) + return if (detail.isEmpty()) reason else "$reason\n$detail" + } + + /** A JSON body becomes plain lines; anything else is kept as the server sent it. */ + private fun readable(body: String): String { + if (!body.startsWith("{") && !body.startsWith("[")) return body + val json = runCatching { JsonParser.parseString(body) }.getOrNull() ?: return body + val lines = when { + json.isJsonObject -> json.asJsonObject.entrySet().map { (name, value) -> + // linkding puts a general message under these names; other names are form fields. + if (name == "detail" || name == "non_field_errors") text(value) else "$name: ${text(value)}" + } + json.isJsonArray -> json.asJsonArray.map(::text) + else -> return body + } + return lines.filter { it.isNotBlank() }.joinToString("\n").ifEmpty { body } + } + + private fun text(value: JsonElement): String = when { + value.isJsonPrimitive -> value.asString + value.isJsonArray -> value.asJsonArray.joinToString(" ", transform = ::text) + else -> value.toString() + } +} + class LinkdingApi( private val clientFactory: (Network?, Long, Long) -> OkHttpClient = HttpClients::create, ) { @@ -148,9 +183,7 @@ class LinkdingApi( return clientFactory(network, 10, 15).newCall(request).executeCancellable { response -> val code = response.code if (code !in 200..299) { - val reason = "linkding returned HTTP $code" - val detail = response.body?.string().orEmpty().trim() - throw ApiException(code, if (detail.isEmpty()) reason else "$reason\n$detail") + throw ApiException(code, ApiErrors.message(code, response.body?.string().orEmpty())) } if (readBody) response.body?.string() else null } diff --git a/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt b/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt index 9652799..4e95a1a 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt @@ -21,17 +21,17 @@ import org.evsyukov.shareding.network.Urls * Reasons saved links cannot be sent: errors that another retry cannot fix without a change in * settings or on the server, and a server that has not been set up yet. */ -enum class SyncProblem(val title: String) { - AUTH("linkding rejected the API token"), - NOT_FOUND("linkding API not found"), - HTTPS("linkding server URL must use HTTPS"), - TOKEN("Cannot read the API token"), - NOT_CONFIGURED("linkding is not set up"); +enum class SyncProblem(val title: String, private val fix: String) { + AUTH("linkding rejected the API token", "Check the API token in Settings."), + NOT_FOUND("linkding API not found", "Check the server URL in Settings."), + HTTPS("linkding server URL must use HTTPS", "Change the server URL in Settings."), + TOKEN("Cannot read the API token", "Enter the API token again in Settings."), + NOT_CONFIGURED("linkding is not set up", "Add the server URL and API token in Settings."); - companion object { - const val NOT_CONFIGURED_DETAIL = - "Saved links stay in the queue. Add the server URL and API token in Settings." + /** Notification text: what happens to saved links and what the user can do. */ + val detail: String get() = "Saved links stay in the queue. $fix" + companion object { /** Why saved links cannot be sent right now, or null while sync can run. */ fun blocking(settings: AppSettings): SyncProblem? = when { !settings.configured -> NOT_CONFIGURED @@ -71,14 +71,14 @@ class SyncNotifier(private val context: Context, private val now: () -> Long = S private val prefs = context.getSharedPreferences("notifications", Context.MODE_PRIVATE) private val manager = NotificationManagerCompat.from(context) - fun problem(problem: SyncProblem, detail: String) { + fun problem(problem: SyncProblem) { if (prefs.getString(KEY_PROBLEM, null) == problem.name) return - if (post(PROBLEM_ID, problem.title, detail)) prefs.edit().putString(KEY_PROBLEM, problem.name).apply() + if (post(PROBLEM_ID, problem.title, problem.detail)) prefs.edit().putString(KEY_PROBLEM, problem.name).apply() } /** Shown on every save while sync cannot run, even after the user dismissed it. */ - fun remind(problem: SyncProblem, detail: String) { - if (post(PROBLEM_ID, problem.title, detail)) prefs.edit().putString(KEY_PROBLEM, problem.name).apply() + fun remind(problem: SyncProblem) { + if (post(PROBLEM_ID, problem.title, problem.detail)) prefs.edit().putString(KEY_PROBLEM, problem.name).apply() } fun resolved() { diff --git a/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt b/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt index c8ef167..5f54590 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt @@ -104,7 +104,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c notify: Boolean = true): Result { container.settings.stopSync(problem.name, message) if (notify) { - container.notifier.problem(problem, message) + container.notifier.problem(problem) container.notifier.checkStale(container.db.bookmarks().oldestCreatedAt()) } container.updateRecovery() diff --git a/app/src/test/java/org/evsyukov/shareding/network/ApiErrorsTest.kt b/app/src/test/java/org/evsyukov/shareding/network/ApiErrorsTest.kt new file mode 100644 index 0000000..cd4e633 --- /dev/null +++ b/app/src/test/java/org/evsyukov/shareding/network/ApiErrorsTest.kt @@ -0,0 +1,29 @@ +package org.evsyukov.shareding.network + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ApiErrorsTest { + @Test fun namesRejectedTokenAndMissingApi() { + assertEquals("linkding rejected the API token (HTTP 401)\nInvalid token.", + ApiErrors.message(401, """{"detail":"Invalid token."}""")) + assertEquals("linkding rejected the API token (HTTP 403)", ApiErrors.message(403, "")) + assertEquals("linkding API not found (HTTP 404)", ApiErrors.message(404, " ")) + } + + @Test fun showsFieldErrorsAsText() { + assertEquals("linkding returned HTTP 400\nurl: Enter a valid URL.\ntag_names: Too long. Not a list.", + ApiErrors.message(400, """{"url":["Enter a valid URL."],"tag_names":["Too long.","Not a list."]}""")) + assertEquals("linkding returned HTTP 400\nThe bookmark already exists.", + ApiErrors.message(400, """{"non_field_errors":["The bookmark already exists."]}""")) + assertEquals("linkding returned HTTP 400\nFirst problem.\nSecond problem.", + ApiErrors.message(400, """["First problem.","Second problem."]""")) + } + + @Test fun keepsBodiesThatAreNotJson() { + assertEquals("linkding returned HTTP 502\nupstream failed", ApiErrors.message(502, "upstream failed\n")) + assertEquals("linkding returned HTTP 500\n{broken", ApiErrors.message(500, "{broken")) + assertEquals("linkding returned HTTP 500\n{}", ApiErrors.message(500, "{}")) + assertEquals("linkding returned HTTP 503", ApiErrors.message(503, "")) + } +} diff --git a/app/src/test/java/org/evsyukov/shareding/network/LinkdingApiTest.kt b/app/src/test/java/org/evsyukov/shareding/network/LinkdingApiTest.kt index 022aae5..f9a2825 100644 --- a/app/src/test/java/org/evsyukov/shareding/network/LinkdingApiTest.kt +++ b/app/src/test/java/org/evsyukov/shareding/network/LinkdingApiTest.kt @@ -87,8 +87,7 @@ class LinkdingApiTest { fail("HTTP error must fail") } catch (error: ApiException) { assertEquals(503, error.code) - assertTrue(error.message.orEmpty().contains("linkding returned HTTP 503")) - assertTrue(error.message.orEmpty().contains("""{"detail":"temporarily unavailable"}""")) + assertEquals("linkding returned HTTP 503\ntemporarily unavailable", error.message) } } } diff --git a/docs/specs/share-to-linkding.md b/docs/specs/share-to-linkding.md index d40ed7d..c90ee3f 100644 --- a/docs/specs/share-to-linkding.md +++ b/docs/specs/share-to-linkding.md @@ -13,7 +13,7 @@ The app accepts a link from Android's Share menu or its add form, saves it on th - R1. Register exactly one `text/plain` `ACTION_SEND` target, so Android does not group several ShareDing entries behind an extra choice. Accept HTTP(S) URLs and save each entry to the local database before making a network request. Keep the sending app visible through a transparent Share activity, show a short `Saved to queue` Toast only after a successful local write, and then finish. Show distinct feedback for invalid URLs, duplicates, or local save errors. - R2. Send queued entries when a network becomes available. The server may be on a LAN or VPN without public internet access. `NET_CAPABILITY_VALIDATED` must not block such servers. Check linkding availability with an API request before sending. - R3. Keep unsent entries after a timeout, HTTP error, connection error, or restart, and keep retrying without a fixed limit. WorkManager uses exponential backoff starting at 30 seconds; Android may run the work later. A successful `POST /api/bookmarks/` removes only the entry that was sent. Errors that retrying cannot fix stop sync instead (R31). -- R4. Let users add a URL manually with title, description, and tags. Fetch page details on request. Settings include the server URL, API token, default tags, unread/archive options, and Test Connection. Keep the connection result visible until the server URL or token changes. The queue shows status chips, Retry for failed entries, Sync now, the most recent attempt time and result, and an empty state with an Add bookmark action. +- R4. Let users add a URL manually with title, description, and tags. Fetch page details on request. Settings include the server URL, API token, default tags, unread/archive options, and Test Connection. Keep the connection result visible until the server URL or token changes. Test Connection checks the values typed in the fields without saving them; after a successful check of a server URL or token that differs from the saved one, Settings tells the user to save settings to sync with this connection. The queue shows status chips, Retry for failed entries, Sync now, the most recent attempt time and result, and an empty state with an Add bookmark action. - R5. Keep the two Queue/Settings tabs, full-screen Add Bookmark form, icon, and brief Share confirmation from the iOS design. About shows the installed version and compact rows linking to the developer's website, the Android source repository, and the public privacy policy. Use native Android controls, Light/Dark themes, and screen reader labels. - R6. Crashes, stopped background work, and reboots must not lose entries. Concurrent additions and manual sync must not remove an entry without confirmed delivery. Adding a duplicate URL must not change an entry while it is being sent. - R7. Allow manual removal of one entry only after user confirmation. Canceling the confirmation keeps the entry and the existing worker. Confirming removal deletes the entry before replacing sync work. @@ -31,12 +31,12 @@ The app accepts a link from Android's Share menu or its add form, saves it on th - R19. Queue Sync now, Retry, saved connection settings, and confirmed deletion replace existing one-time sync work or create it if missing. Sync now, Retry, and saved settings also resume a stopped sync (R31); deletion does not. While sync cannot run, these actions cancel pending work and create none. Cancel active requests, reset retry delay, and read the current queue. Network constraints still apply. Before each POST, skip entries deleted since the batch was read. A request already accepted by linkding cannot be recalled. - R20. Queue shows current running/waiting state and the most recent attempt's result and timestamp in small text, including when empty. Record failure time as well as success time. A successful later item in a partly failed batch must not clear the failure. Settings has no Sync section. - R21. Tapping a queue card expands or collapses its details. Expanded title, URL, and error text wrap without ellipsis and support selection and copying. Separate Delete and Retry controls keep their existing actions. -- R22. HTTP errors retain their status code and full response body in the queued entry and latest sync error. Preserve JSON validation details and plain text; an empty response still shows the HTTP code. +- R22. HTTP errors keep their status code and the server's explanation in the queued entry, the latest sync error, and the Test Connection result. The first line names the error: `linkding rejected the API token (HTTP 401)` for 401 and 403, `linkding API not found (HTTP 404)`, otherwise `linkding returned HTTP `. A JSON body follows as text, not as JSON: `detail` and `non_field_errors` as they are, field errors as `field: message`. A plain-text body is kept unchanged; an empty response shows only the first line. - R23. Queued entries cannot be edited. An expanded card shows details and offers only Retry for a failed entry and Delete. Nothing in the app pauses sync. - R25. Before a URL enters the queue from Share or Add Bookmark, remove known tracking query parameters: names starting with `utm_`, common click identifiers such as `fbclid`, `gclid`, `yclid`, and `msclkid`, and `si` on YouTube and Spotify hosts. Keep every other parameter, their order and encoding, and the fragment. Drop `?` when no parameter remains. A URL without tracking parameters is stored unchanged, and cleanup runs before the duplicate check. - R26. Before sending a queued bookmark, the sync worker resolves links on a fixed list of URL shorteners (`t.co`, `bit.ly`, `vk.cc`, `clck.ru`, and similar). It sends GET requests without the linkding token and without following redirects, reads `Location` from a 3xx response, and repeats while the target is still a listed shortener, up to five requests. The first host outside the list ends resolution; that site is never contacted. The resolved URL gets R25 cleanup and replaces the URL in the linkding request, and `Original URL: ` is appended to the notes. Any failure (timeout of about five seconds per request, network error, non-redirect response, invalid `Location`, too many hops) sends the original URL unchanged and does not by itself fail delivery. The queued entry keeps the short URL. - R27. The Add Bookmark URL field has a paste button. It reads the clipboard only when tapped and replaces the field with the first HTTP(S) link found in the clipboard text, extracted and cleaned as in Share. Without a link, it shows a short message and leaves the field unchanged. -- R28. Sync errors that retrying cannot fix (HTTP 401/403, 404, the HTTPS requirement, an unreadable token) show one notification per error kind until the kind changes or a sync succeeds. If the oldest queued link is older than 24 hours while server settings are present, show one "waiting" notification for that link. Tapping a notification opens the app; a successful sync or an empty queue removes them. Transient errors do not notify. Saving a link while sync cannot run (R31) shows the notification for the current reason again, even after the user dismissed it, and adds the reason to the save confirmation: `Saved to queue · linkding is not set up` or `Saved to queue · sync is stopped`. Saving settings removes the notification. On Android 13+, request the notification permission once, the first time the main screen opens. The app has no notification setting of its own: after that the user turns alerts on or off in the system settings. Without the permission or with notifications turned off, sync behaves the same and errors stay visible in Queue. +- R28. Sync errors that retrying cannot fix (HTTP 401/403, 404, the HTTPS requirement, an unreadable token) show one notification per error kind until the kind changes or a sync succeeds. Its title names the problem and its text says that saved links stay in the queue and what to change in Settings; the server response is not repeated there. If the oldest queued link is older than 24 hours while server settings are present, show one "waiting" notification for that link. Tapping a notification opens the app; a successful sync or an empty queue removes them. Transient errors do not notify. Saving a link while sync cannot run (R31) shows the notification for the current reason again, even after the user dismissed it, and adds the reason to the save confirmation: `Saved to queue · linkding is not set up` or `Saved to queue · sync is stopped`. Saving settings removes the notification. On Android 13+, request the notification permission once, the first time the main screen opens. The app has no notification setting of its own: after that the user turns alerts on or off in the system settings. Without the permission or with notifications turned off, sync behaves the same and errors stay visible in Queue. - R29. Leaving the Add Bookmark form with a changed draft asks for confirmation; an unchanged draft closes directly. - R30. Every interactive control has a name a screen reader can announce. A setting switch and its label form one toggleable element, so the label is read with the state and a tap anywhere on the row toggles it. On screens wider than 640 dp the queue, both forms, Settings, and the Save buttons keep a 640 dp width and stay centred; narrower screens use the full width. - R31. Sync can run only when the server URL and API token are set and no persistent error has stopped it. A persistent error is HTTP 401/403, HTTP 404, the HTTPS requirement, or an unreadable token, from the server check or from a POST. The worker marks the affected entry failed (after a failed server check, up to the first 50 entries), keeps later entries pending, records the reason, and finishes without a retry. The reason survives a restart. While sync cannot run, Share, Add Bookmark, deletion, app startup, and new networks create no WorkManager work, and the Queue status line shows `Set up linkding in Settings to sync` or `Sync stopped ·` with the problem. Sync resumes only through Sync now, Retry, or saved settings; a successful sync clears the reason. @@ -76,19 +76,19 @@ For R16–R17, Share titles serve as local labels. A persisted flag distinguishe - R15: Save 50 links while sync is waiting for a network: one unfinished request remains. Save links while sync is running: one successor remains blocked without canceling the running job. After it finishes, the successor processes any link it missed. - R18: With an empty queue or missing server settings, run recovery and observe no new sync request. With a queued link and no sync request, recovery adds exactly one. With an enqueued retry or running upload, recovery keeps its WorkManager ID and adds no successor. Repeated registration keeps one periodic request. After an ordinary process kill or reboot, WorkManager can run the saved work without reopening the UI. - R2, R9: Reach an HTTPS linkding server through LAN/VPN without public internet validation. Load an existing HTTP server setting and verify that Test Connection and background sync show the HTTPS requirement, send no token or queued bookmark, and preserve the queue until the URL is corrected. HTTP bookmark URLs and page metadata fetches remain available. -- R4, R5: The add form, Queue, and Settings perform their actions and show the agreed states in Light/Dark themes. +- R4, R5: The add form, Queue, and Settings perform their actions and show the agreed states in Light/Dark themes. Test an unsaved token and confirm the save reminder appears and goes away after saving; covered by `QueueUiTest.testedButUnsavedTokenAsksToSaveSettings`. - R7, R19: Cancel removal and keep the entry and worker; confirm removal and replace work after deleting only the selected entry. Remove a later batch entry during an earlier POST and verify the removed URL is never sent. Delete an entry whose upload is awaiting a response and verify remaining entries send before the old request timeout. Covered by `QueueSyncInteractionTest`, `SyncSchedulerTest`, and `QueueUiTest`. - R10: Enter default tags, save from the bottom of Settings, and verify persistence and keyboard dismissal. Type a partial tag name and select a server suggestion in either form; verify comma-separated names and local saving. Return an API error or disconnect the server and verify that manual tag entry and saving still work. - R11: Fetch an HTML page containing title, description, and keywords. Confirm all available fields appear and can be edited before saving. Omitted metadata and a failed request do not clear manual values. - R5, R12: Open About and confirm the app icon and identity appear above version and links, including the public privacy policy. - R13, R14: Upgrade from a build with a saved proxy and credentials; confirm the proxy values and encrypted password are gone while the queue, server URL, token, and defaults remain. Confirm Settings shows no proxy options and that sync and page fetches reach their origins directly. Covered by `SettingsStoreProxyCleanupTest`, `QueueUiTest`, and `SyncWorkerTest`. - R16, R17: Share a multiline non-ASCII title and verify it remains local while the POST omits title and no page request occurs. Save a form title, then confirm it is sent as one line with at most 512 Unicode code points. Share a title equal to the link and the link split by whitespace and verify that no title is stored. Verify the same behavior after retries and a version 1 database upgrade. Covered by `ShareIntentTest`, `SyncWorkerTest`, `LinkdingApiTest`, `BookmarkTitlesTest`, and `BookmarkMigrationTest`. Explicit HTML fetch tests retain charset coverage in `PageMetadataTest`. -- R20–R22: Expand a long title, URL, and error, verify none is ellipsized, and collapse again. Return a structured HTTP error, retain its body and attempt time, then manually sync successfully and show that result with an empty queue. Settings has no Sync Now action. Covered by `QueueUiTest`, `LinkdingApiTest`, and `SyncWorkerTest`. +- R20–R22: Expand a long title, URL, and error, verify none is ellipsized, and collapse again. Return a structured HTTP error, show its details as text and keep the attempt time, then manually sync successfully and show that result with an empty queue. Settings has no Sync Now action. Covered by `QueueUiTest`, `ApiErrorsTest`, `LinkdingApiTest`, and `SyncWorkerTest`. - R23: Expand a failed queue card and confirm it offers Retry and Delete and no Edit. Covered by `QueueUiTest.cardExpandsFullSelectableTitleUrlAndErrorThenCollapses`. - R25: Share and add URLs with tracking parameters, host-specific `si`, encoded names, and URLs without tracking parameters; confirm the stored URL. Covered by `TrackingParamsTest`, `UrlsTest`, and `ShareIntentTest`. - R26: Resolve a single redirect, a chain of shorteners, and failures (timeout, disconnect, 200 page, redirect without or with invalid `Location`, six hops); confirm the request carries no token and only listed hosts are contacted. Covered by `ShortLinkResolverTest` and `SyncWorkerTest.workerSendsResolvedShortLinkAndKeepsOriginalInNotes`. - R27: Paste text containing a tracked link and text without a link. Covered by `QueueUiTest.pasteButtonFillsUrlFromClipboard` and `QueueUiTest.pasteWithoutLinkKeepsUrlField`. -- R28: Classify errors, show and deduplicate problem and waiting notifications, and clear them after success. On Android 13+ a fresh install shows the permission dialog on first launch and, after a denial, not again; Settings has no notification row. Covered by `SyncProblemTest`, `NotificationAccessTest`, `SyncNotifierTest`, and `QueueUiTest.settingsHaveNoNotificationRow`; the denial flow was checked manually on API 36. +- R28: Classify errors, show and deduplicate problem and waiting notifications, and clear them after success. On Android 13+ a fresh install shows the permission dialog on first launch and, after a denial, not again; Settings has no notification row. A rejected token shows the instruction, not the server response. Covered by `SyncProblemTest`, `NotificationAccessTest`, `SyncNotifierTest`, and `QueueUiTest.settingsHaveNoNotificationRow`; the denial flow was checked manually on API 36. - R1, R29: Confirm the package exposes one Share target labelled ShareDing; leave the Add Bookmark form with a typed link and confirm the discard prompt. Covered by `ShareIntentTest.appearsInTextShareTargets` and `QueueUiTest.addFormAsksBeforeDiscardingTypedLink`. - R30: Find "Mark unread" and "Archive" in Settings as switches and toggle one by its label; covered by `QueueUiTest.bookmarkDefaultSwitchesAreLabelledAndToggleFromTheirRow`. The width limit was checked on an API 36 emulator set to a 1280 × 800 dp landscape display and to the default phone size; it has no automated test. - R31: Share and save without server settings, then after a 401: the link is stored, no sync or recovery work exists, and the notification names the reason. Return 401 from the server check and 403 from a POST: the worker succeeds without a retry, a second run sends nothing, and Sync now delivers the queue once the server accepts the token. Covered by `SyncSchedulerTest`, `SyncRecoveryTest`, `SyncWorkerTest`, `SyncNotifierTest`, `ShareIntentTest`, `SyncProblemTest`, and `QueueUiTest`.