From 777725a971efa403b5e9ddc9e71c455e8413a5a2 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:20:57 +0300 Subject: [PATCH 1/7] feat(sync): do not schedule work without a usable server Sync can run only when the server URL and token are set and no persistent error has stopped it. The scheduler checks this itself, so sharing, the add form, deletion, startup, and new networks no longer enqueue a job that would start and exit. The periodic recovery check is registered only while sync can run. Refs #35 --- .../evsyukov/shareding/SyncRecoveryTest.kt | 24 ++++++++++++++++++ .../evsyukov/shareding/SyncSchedulerTest.kt | 23 +++++++++++++++++ .../org/evsyukov/shareding/MainActivity.kt | 10 ++++---- .../shareding/ShareDingApplication.kt | 12 ++++++--- .../evsyukov/shareding/data/SettingsStore.kt | 25 +++++++++++++++++-- .../evsyukov/shareding/sync/SyncScheduler.kt | 25 ++++++++++++++----- .../org/evsyukov/shareding/sync/SyncWorker.kt | 2 +- 7 files changed, 103 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncRecoveryTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncRecoveryTest.kt index 22da2d2..8cc1ac5 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncRecoveryTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncRecoveryTest.kt @@ -53,6 +53,30 @@ class SyncRecoveryTest { } } + @Test fun periodicRecoveryExistsOnlyWhileSyncCanRun() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val app = context.applicationContext as ShareDingApplication + val workManager = WorkManager.getInstance(context) + fun recovery() = workManager.getWorkInfosForUniqueWork("linkding-sync-recovery").get() + .filter { !it.state.isFinished } + try { + app.container.settings.save("", null, "", true, false) + app.container.updateRecovery() + assertTrue(recovery().isEmpty()) + + app.container.settings.save("https://linkding.example/", "test-token", "", true, false) + app.container.updateRecovery() + assertEquals(1, recovery().size) + + app.container.settings.stopSync("AUTH", "linkding returned HTTP 401") + app.container.updateRecovery() + assertTrue(recovery().isEmpty()) + } finally { + app.container.settings.save("", null, "", true, false) + app.container.updateRecovery() + } + } + @Test fun emptyQueueDoesNotScheduleSync() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val app = context.applicationContext as ShareDingApplication diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncSchedulerTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncSchedulerTest.kt index 75bbf57..928c8b7 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncSchedulerTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncSchedulerTest.kt @@ -98,6 +98,29 @@ class SyncSchedulerTest { } } + @Test fun schedulerCreatesNoWorkWhileSyncCannotRun() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork("linkding-sync").result.get() + try { + val scheduler = SyncScheduler(context) { false } + scheduler.requestSync() + scheduler.ensureScheduled() + assertTrue(workManager.getWorkInfosForUniqueWork("linkding-sync").get() + .none { !it.state.isFinished }) + + val existing = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.DAYS).build() + workManager.enqueueUniqueWork("linkding-sync", ExistingWorkPolicy.KEEP, + existing).result.get() + scheduler.restartSync() + assertTrue(workManager.getWorkInfosForUniqueWork("linkding-sync").get() + .none { !it.state.isFinished }) + } finally { + workManager.cancelUniqueWork("linkding-sync").result.get() + } + } + @Test fun restartCreatesWorkWhenNoCurrentSyncExists() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val workManager = WorkManager.getInstance(context) diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index 53c0ff4..1469121 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -207,7 +207,7 @@ private fun ShareDingScreen(container: AppContainer) { LaunchedEffect(settings.serverUrl, settings.hasToken, selectedTab, showAdd, tagRefresh) { serverTags = emptyList() tagLoadError = false - if ((!showAdd && selectedTab != 1) || settings.serverUrl.isBlank() || !settings.hasToken) { + if ((!showAdd && selectedTab != 1) || !settings.configured) { return@LaunchedEffect } try { @@ -222,7 +222,7 @@ private fun ShareDingScreen(container: AppContainer) { if (showAdd) { AddBookmarkScreen(container, serverTags, tagLoadError, - canRefreshTags = settings.hasToken && settings.serverUrl.isNotBlank(), + canRefreshTags = settings.configured, onRefreshTags = { tagRefresh++ }, onDismiss = { showAdd = false }) return } @@ -269,6 +269,7 @@ private fun ShareDingScreen(container: AppContainer) { withContext(Dispatchers.IO) { container.settings.save(server.trim(), token, tags.trim(), unread, archived) } + container.updateRecovery() container.scheduler.restartSync() }, onTest = { server, token -> @@ -293,7 +294,7 @@ private fun QueueSyncStatus(settings: Settings, workInfos: List, queue val active = workInfos.filter { !it.state.isFinished } val status = when { active.any { it.state == WorkInfo.State.RUNNING } -> "Syncing…" - queueCount > 0 && (settings.serverUrl.isBlank() || !settings.hasToken) -> "Set up linkding in Settings to sync" + queueCount > 0 && !settings.configured -> "Set up linkding in Settings to sync" queueCount > 0 && active.isNotEmpty() -> "Waiting for network or retry" else -> "Ready to sync" } @@ -739,8 +740,7 @@ private fun SettingsScreen(settings: Settings, notifier: SyncNotifier, padding: if (savedConnectionIsCurrent) availableTags else emptyList(), "Comma-separated: reading, work notes. Added to new bookmarks.", tagLoadError && savedConnectionIsCurrent, - canRefresh = settings.hasToken && settings.serverUrl.isNotBlank() && - savedConnectionIsCurrent, onRefreshTags = onRefreshTags) + canRefresh = settings.configured && savedConnectionIsCurrent, onRefreshTags = onRefreshTags) SwitchRow("Mark unread", unread) { unread = it; saveStatus = null } SwitchRow("Archive", archived) { archived = it; saveStatus = null } } diff --git a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt index f425e92..55d9ee4 100644 --- a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt +++ b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt @@ -29,7 +29,7 @@ class ShareDingApplication : Application() { container = AppContainer(this) startupScope.launch { try { - container.scheduler.ensureRecoveryScheduled() + container.updateRecovery() } catch (cancel: CancellationException) { throw cancel } catch (error: Exception) { @@ -56,7 +56,7 @@ class AppContainer(application: Application) { internal var shortLinks = ShortLinkResolver() val networkTracker = NetworkTracker(application) val networkSelector = NetworkSelector(networkTracker, api, pageFetcher) - val scheduler = SyncScheduler(application) + val scheduler = SyncScheduler(application) { settings.state.value.canSync } val notifier = SyncNotifier(application) suspend fun deleteQueuedBookmark(id: Long) { @@ -64,13 +64,17 @@ class AppContainer(application: Application) { scheduler.restartSync() } + /** Keeps the periodic recovery check only while sync can run. */ + suspend fun updateRecovery() { + if (settings.state.value.canSync) scheduler.ensureRecoveryScheduled() else scheduler.cancelRecovery() + } + suspend fun recoverQueuedSync() { if (db.bookmarks().count() == 0) { notifier.checkStale(null) return } - val configured = settings.state.value - if (configured.serverUrl.isBlank() || !configured.hasToken) return + if (!settings.state.value.canSync) return notifier.checkStale(db.bookmarks().oldestCreatedAt()) scheduler.ensureScheduled() } diff --git a/app/src/main/java/org/evsyukov/shareding/data/SettingsStore.kt b/app/src/main/java/org/evsyukov/shareding/data/SettingsStore.kt index 43c4844..d43c032 100644 --- a/app/src/main/java/org/evsyukov/shareding/data/SettingsStore.kt +++ b/app/src/main/java/org/evsyukov/shareding/data/SettingsStore.kt @@ -23,7 +23,14 @@ data class Settings( val lastSyncAttempt: Long = 0, val lastError: String = "", val hasToken: Boolean = false, -) + /** Name of the persistent sync problem that stopped sync, or empty while sync may run. */ + val syncStopped: String = "", +) { + val configured: Boolean get() = serverUrl.isNotBlank() && hasToken + + /** Sync needs a server and must not be stopped; otherwise no work is scheduled at all. */ + val canSync: Boolean get() = configured && syncStopped.isEmpty() +} class SettingsStore(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -46,6 +53,7 @@ class SettingsStore(context: Context) { lastSyncAttempt = prefs.getLong("last_sync_attempt", prefs.getLong("last_sync", 0)), lastError = prefs.getString("last_error", "") ?: "", hasToken = secrets.contains("token"), + syncStopped = prefs.getString("sync_stopped", "") ?: "", ) // Proxy support was removed; drop settings and the password saved by older versions. @@ -67,6 +75,7 @@ class SettingsStore(context: Context) { } val editor = prefs.edit().putString("server_url", serverUrl) .putString("tags", tags).putBoolean("unread", unread).putBoolean("archived", archived) + .remove("sync_stopped") check(editor.commit()) { "Cannot save settings" } mutable.value = read() } @@ -76,7 +85,19 @@ class SettingsStore(context: Context) { fun recordSuccess() { val now = System.currentTimeMillis() check(prefs.edit().putLong("last_sync", now).putLong("last_sync_attempt", now) - .putString("last_error", "").commit()) + .putString("last_error", "").remove("sync_stopped").commit()) + mutable.value = read() + } + + /** Records a problem that retrying cannot fix; sync stays off until [resumeSync] or [save]. */ + fun stopSync(problem: String, message: String) { + check(prefs.edit().putString("sync_stopped", problem).putString("last_error", message) + .putLong("last_sync_attempt", System.currentTimeMillis()).commit()) + mutable.value = read() + } + + fun resumeSync() { + check(prefs.edit().remove("sync_stopped").commit()) mutable.value = read() } diff --git a/app/src/main/java/org/evsyukov/shareding/sync/SyncScheduler.kt b/app/src/main/java/org/evsyukov/shareding/sync/SyncScheduler.kt index 7f2075b..37b3061 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncScheduler.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncScheduler.kt @@ -20,7 +20,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -class SyncScheduler(context: Context) { +/** Schedules nothing while [canSync] is false: no server is set up, or a persistent error stopped sync. */ +class SyncScheduler(context: Context, private val canSync: () -> Boolean = { true }) { private val workManager = WorkManager.getInstance(context) private val scheduling = Mutex() @@ -28,14 +29,21 @@ class SyncScheduler(context: Context) { suspend fun restartSync() = scheduling.withLock { withContext(Dispatchers.IO) { - workManager.enqueueUniqueWork("linkding-sync", ExistingWorkPolicy.REPLACE, newRequest()) - .result.get() + if (canSync()) { + workManager.enqueueUniqueWork("linkding-sync", ExistingWorkPolicy.REPLACE, newRequest()) + .result.get() + } else { + workManager.cancelUniqueWork("linkding-sync").result.get() + } } } - suspend fun ensureScheduled() = withContext(Dispatchers.IO) { - workManager.enqueueUniqueWork("linkding-sync", ExistingWorkPolicy.KEEP, newRequest()) - .result.get() + suspend fun ensureScheduled() { + if (!canSync()) return + withContext(Dispatchers.IO) { + workManager.enqueueUniqueWork("linkding-sync", ExistingWorkPolicy.KEEP, newRequest()) + .result.get() + } } suspend fun ensureRecoveryScheduled() = withContext(Dispatchers.IO) { @@ -43,7 +51,12 @@ class SyncScheduler(context: Context) { newRecoveryRequest()).result.get() } + suspend fun cancelRecovery() = withContext(Dispatchers.IO) { + workManager.cancelUniqueWork("linkding-sync-recovery").result.get() + } + suspend fun requestSync() { + if (!canSync()) return try { scheduling.withLock { withContext(Dispatchers.IO) { 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 0ad73ff..2dfdab9 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt @@ -27,7 +27,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c val dao = container.db.bookmarks() dao.recoverInterrupted() val settings = container.settings.state.value - if (settings.serverUrl.isBlank() || !settings.hasToken) return Result.success() + if (!settings.canSync) return Result.success() val token = try { container.settings.token() } catch (cancel: CancellationException) { throw cancel } catch (error: Exception) { From d5e7c47eb14b50cab63e3b027aa1173871cd9ff0 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:25:41 +0300 Subject: [PATCH 2/7] feat(sync): stop retrying after a persistent server error A rejected token (401/403), a missing API (404), a non-HTTPS server URL, or an unreadable token cannot be fixed by another attempt. The worker now records the problem and finishes without a retry, and no sync work is scheduled until the user saves settings, taps Sync now, or retries a link. Transient errors keep the exponential backoff. Refs #35 --- .../org/evsyukov/shareding/QueueUiTest.kt | 26 ++++++++ .../evsyukov/shareding/SyncNotifierTest.kt | 2 + .../org/evsyukov/shareding/SyncWorkerTest.kt | 60 +++++++++++++++++++ .../org/evsyukov/shareding/MainActivity.kt | 6 +- .../shareding/ShareDingApplication.kt | 8 +++ .../org/evsyukov/shareding/sync/SyncWorker.kt | 37 +++++++----- 6 files changed, 123 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt index d035d83..c9847d5 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt @@ -70,6 +70,8 @@ class QueueUiTest { @Test fun deletingBookmarkRequiresConfirmation() { val app = compose.activity.application as ShareDingApplication val url = "https://example.com/delete-${System.nanoTime()}" + // Deletion restarts sync only while a server is set up; this one refuses connections. + app.container.settings.save("https://127.0.0.1:1/", "test-token", "", true, false) runBlocking { withContext(Dispatchers.IO) { app.container.db.clearAllTables() @@ -164,6 +166,30 @@ class QueueUiTest { } } + @Test fun syncNowResumesSyncStoppedByRejectedToken() { + 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) = MockResponse().setResponseCode( + if (request.method == "POST") 201 else 200).setBody("{}") + } + val app = compose.activity.application as ShareDingApplication + runBlocking { + withContext(Dispatchers.IO) { app.container.db.clearAllTables() } + app.container.db.bookmarks().insert(Bookmark(url = "https://example.com/resumed")) + } + app.container.settings.save(tls.url(server), "secret", "", true, false) + app.container.settings.stopSync("AUTH", "linkding returned HTTP 401") + runBlocking { app.container.scheduler.requestSync() } + assertTrue(WorkManager.getInstance(compose.activity) + .getWorkInfosForUniqueWork("linkding-sync").get().none { !it.state.isFinished }) + + compose.onNodeWithContentDescription("Sync now").performClick() + compose.waitUntil(10_000) { runBlocking { app.container.db.bookmarks().count() == 0 } } + assertEquals("", app.container.settings.state.value.syncStopped) + } + } + @Test fun httpServerShowsHttpsRequirement() { val app = compose.activity.application as ShareDingApplication app.container.settings.save("http://example.com", null, "", true, false) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt index 73f91e8..4982362 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt @@ -115,6 +115,8 @@ class SyncNotifierTest { assertEquals(SyncProblem.AUTH.title, active(SyncNotifier.PROBLEM_ID)?.notification?.extras?.getString("android.title")) + // A rejected token stops sync; the next attempt needs Sync now, Retry, or new settings. + app.container.settings.resumeSync() server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) TestListenableWorkerBuilder(context).build().doWork() diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt index 36ed5a3..be9c9a1 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt @@ -228,6 +228,65 @@ class SyncWorkerTest { } } + @Test fun rejectedTokenStopsSyncUntilTheUserResumes() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val tls = AndroidTestTls(InstrumentationRegistry.getInstrumentation().context) + val app = context.applicationContext as ShareDingApplication + WorkManager.getInstance(context).cancelUniqueWork("linkding-sync").result.get() + val dao = app.container.db.bookmarks() + withContext(Dispatchers.IO) { app.container.db.clearAllTables() } + MockWebServer().apply { tls.start(this) }.use { server -> + try { + app.container.settings.save(tls.url(server), "revoked", "", true, false) + dao.insert(Bookmark(url = "https://example.com/stopped")) + server.enqueue(MockResponse().setResponseCode(401).setBody("Invalid token.")) + val result = TestListenableWorkerBuilder(context).build().doWork() + assertEquals(androidx.work.ListenableWorker.Result.success(), result) + assertEquals("AUTH", app.container.settings.state.value.syncStopped) + assertEquals("failed", dao.batch(1).single().status) + assertEquals(1, server.requestCount) + + TestListenableWorkerBuilder(context).build().doWork() + assertEquals(1, server.requestCount) + + app.container.settings.resumeSync() + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + TestListenableWorkerBuilder(context).build().doWork() + assertEquals(0, dao.count()) + assertEquals("", app.container.settings.state.value.syncStopped) + } finally { + app.container.settings.save("", null, "", true, false) + } + } + } + + @Test fun forbiddenPostStopsSyncAndLeavesLaterEntriesPending() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val tls = AndroidTestTls(InstrumentationRegistry.getInstrumentation().context) + val app = context.applicationContext as ShareDingApplication + WorkManager.getInstance(context).cancelUniqueWork("linkding-sync").result.get() + val dao = app.container.db.bookmarks() + withContext(Dispatchers.IO) { app.container.db.clearAllTables() } + MockWebServer().apply { tls.start(this) }.use { server -> + try { + app.container.settings.save(tls.url(server), "read-only", "", true, false) + dao.insert(Bookmark(url = "https://example.com/first", createdAt = 1)) + dao.insert(Bookmark(url = "https://example.com/second", createdAt = 2)) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.enqueue(MockResponse().setResponseCode(403).setBody("Forbidden")) + val result = TestListenableWorkerBuilder(context).build().doWork() + assertEquals(androidx.work.ListenableWorker.Result.success(), result) + assertEquals("AUTH", app.container.settings.state.value.syncStopped) + assertEquals(2, server.requestCount) + assertEquals("failed", dao.findByUrl("https://example.com/first")?.status) + assertEquals("pending", dao.findByUrl("https://example.com/second")?.status) + } finally { + app.container.settings.save("", null, "", true, false) + } + } + } + @Test fun httpLinkdingServerRetainsQueueWithoutSendingToken() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val app = context.applicationContext as ShareDingApplication @@ -246,6 +305,7 @@ class SyncWorkerTest { assertEquals(1, dao.count()) assertEquals("Keep this bookmark", dao.findByUrl(url)?.title) assertEquals("failed", dao.findByUrl(url)?.status) + assertEquals("HTTPS", app.container.settings.state.value.syncStopped) } finally { app.container.settings.save("", null, "", true, false) } diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index 1469121..4df7205 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -231,7 +231,7 @@ private fun ShareDingScreen(container: AppContainer) { topBar = { TopAppBar(title = { Text(if (selectedTab == 0) "Queue" else "Settings") }, actions = { if (selectedTab == 0) IconButton(onClick = { - runQueueAction { container.scheduler.restartSync() } + runQueueAction { container.resumeSync() } }, enabled = !scheduling) { Icon(Icons.Default.Refresh, contentDescription = "Sync now") } @@ -258,7 +258,7 @@ private fun ShareDingScreen(container: AppContainer) { onRetry = { bookmark -> runQueueAction { withContext(Dispatchers.IO) { container.db.bookmarks().markPending(bookmark.id) } - container.scheduler.restartSync() + container.resumeSync() } }, onDelete = { deleteTarget = it }) @@ -269,6 +269,8 @@ private fun ShareDingScreen(container: AppContainer) { withContext(Dispatchers.IO) { container.settings.save(server.trim(), token, tags.trim(), unread, archived) } + // The worker reports again if the saved connection still does not work. + container.notifier.resolved() container.updateRecovery() container.scheduler.restartSync() }, diff --git a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt index 55d9ee4..216c455 100644 --- a/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt +++ b/app/src/main/java/org/evsyukov/shareding/ShareDingApplication.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.evsyukov.shareding.data.AppDatabase import org.evsyukov.shareding.data.SettingsStore import org.evsyukov.shareding.network.LinkdingApi @@ -69,6 +70,13 @@ class AppContainer(application: Application) { if (settings.state.value.canSync) scheduler.ensureRecoveryScheduled() else scheduler.cancelRecovery() } + /** Sync now and Retry ask for another attempt, so a stopped sync starts again. */ + suspend fun resumeSync() { + withContext(Dispatchers.IO) { settings.resumeSync() } + updateRecovery() + scheduler.restartSync() + } + suspend fun recoverQueuedSync() { if (db.bookmarks().count() == 0) { notifier.checkStale(null) 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 2dfdab9..d421cab 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncWorker.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import org.evsyukov.shareding.ShareDingApplication -import org.evsyukov.shareding.network.ApiException +import org.evsyukov.shareding.AppContainer class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = try { @@ -32,10 +32,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c throw cancel } catch (error: Exception) { Log.e("ShareDing", "Cannot read API token", error) - val message = "Cannot read API token: ${error.message}" - runCatching { container.settings.recordError(message) } - container.notifier.problem(SyncProblem.TOKEN, message) - return Result.success() + return stop(container, SyncProblem.TOKEN, "Cannot read API token: ${error.message}") } ?: return Result.success() // An empty queue still checks the server, so "Sync now" reports a fresh result. val selectedNetwork = try { @@ -44,14 +41,12 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c throw cancel } catch (error: Exception) { val message = error.message ?: "Cannot reach linkding" - if (dao.count() == 0) { - // Nothing is waiting, so there is nothing to retry or notify about. - runCatching { container.settings.recordError(message) } - return Result.success() - } - dao.batch(50).forEach { dao.markFailed(it.id, message) } + val queued = dao.count() > 0 + if (queued) dao.batch(50).forEach { dao.markFailed(it.id, message) } + SyncProblem.of(error)?.let { return stop(container, it, message, notify = queued) } runCatching { container.settings.recordError(message) } - SyncProblem.of(error)?.let { container.notifier.problem(it, message) } + // Nothing is waiting, so there is nothing to retry or notify about. + if (!queued) return Result.success() container.notifier.checkStale(dao.oldestCreatedAt()) return Result.retry() } @@ -86,10 +81,9 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c } catch (error: Exception) { val message = error.message ?: "Cannot send bookmark" dao.markFailed(bookmark.id, message) + SyncProblem.of(error)?.let { return stop(container, it, message) } runCatching { container.settings.recordError(message) } - SyncProblem.of(error)?.let { container.notifier.problem(it, message) } failed = true - if (error is ApiException && error.code == 401) break } } if (failed) { @@ -99,4 +93,19 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c } return Result.retry() } + + /** + * Ends the run after a problem that retrying cannot fix. Sync stays off, with no scheduled work, + * until the user saves settings, taps Sync now, or retries a link. + */ + private suspend fun stop(container: AppContainer, problem: SyncProblem, message: String, + notify: Boolean = true): Result { + container.settings.stopSync(problem.name, message) + if (notify) { + container.notifier.problem(problem, message) + container.notifier.checkStale(container.db.bookmarks().oldestCreatedAt()) + } + container.updateRecovery() + return Result.success() + } } From 8df0cca0b0a6a1250f77a89e42b38831a3328ba8 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:28:16 +0300 Subject: [PATCH 3/7] feat(share): tell the user when a saved link cannot be sent Saving a link while the server is not set up, or while a persistent error has stopped sync, now adds the reason to the Toast and shows a notification that returns on every such save. The Queue status line names the problem that stopped sync. Refs #35 --- .../org/evsyukov/shareding/QueueUiTest.kt | 7 +++ .../org/evsyukov/shareding/ShareIntentTest.kt | 58 +++++++++++++++++++ .../evsyukov/shareding/SyncNotifierTest.kt | 12 ++++ .../org/evsyukov/shareding/MainActivity.kt | 9 ++- .../org/evsyukov/shareding/ShareActivity.kt | 4 +- .../shareding/ShareDingApplication.kt | 14 +++++ .../evsyukov/shareding/sync/SyncNotifier.kt | 33 ++++++++++- .../shareding/sync/SyncProblemTest.kt | 22 +++++++ 8 files changed, 152 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt index c9847d5..d4c729b 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt @@ -190,6 +190,13 @@ class QueueUiTest { } } + @Test fun queueStatusNamesTheProblemThatStoppedSync() { + val app = compose.activity.application as ShareDingApplication + app.container.settings.save("https://127.0.0.1:1/", "revoked", "", true, false) + app.container.settings.stopSync("AUTH", "linkding returned HTTP 401") + compose.onNodeWithText("Sync stopped · linkding rejected the API token").assertIsDisplayed() + } + @Test fun httpServerShowsHttpsRequirement() { val app = compose.activity.application as ShareDingApplication app.container.settings.save("http://example.com", null, "", true, false) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt index 602853e..9eeec82 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt @@ -1,20 +1,78 @@ package org.evsyukov.shareding +import android.app.NotificationManager +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.WorkManager import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.evsyukov.shareding.sync.SyncNotifier +import org.evsyukov.shareding.sync.SyncProblem +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ShareIntentTest { + @get:Rule val notificationPermission = NotificationPermissionRule() + + private fun problemTitle(context: Context): String? { + val manager = context.getSystemService(NotificationManager::class.java) + repeat(40) { + manager.activeNotifications.firstOrNull { it.id == SyncNotifier.PROBLEM_ID } + ?.let { return it.notification.extras.getString("android.title") } + Thread.sleep(50) + } + return null + } + + @Test fun shareWhileSyncCannotRunSavesTheLinkAndNotifiesWithoutSchedulingWork() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val app = context.applicationContext as ShareDingApplication + val workManager = WorkManager.getInstance(context) + val notifications = context.getSystemService(NotificationManager::class.java) + suspend fun share(url: String) { + context.startActivity(Intent(context, ShareActivity::class.java).apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + for (attempt in 0 until 30) { + if (app.container.db.bookmarks().findByUrl(url) != null) return + delay(100) + } + throw AssertionError("Shared link was not saved: $url") + } + workManager.cancelUniqueWork("linkding-sync").result.get() + notifications.cancelAll() + try { + app.container.settings.save("", null, "", true, false) + share("https://example.com/unconfigured-${System.nanoTime()}") + assertEquals(SyncProblem.NOT_CONFIGURED.title, problemTitle(context)) + assertTrue(workManager.getWorkInfosForUniqueWork("linkding-sync").get() + .none { !it.state.isFinished }) + + notifications.cancelAll() + app.container.settings.save("https://127.0.0.1:1/", "revoked", "", true, false) + app.container.settings.stopSync("AUTH", "linkding returned HTTP 401") + share("https://example.com/stopped-${System.nanoTime()}") + assertEquals(SyncProblem.AUTH.title, problemTitle(context)) + assertTrue(workManager.getWorkInfosForUniqueWork("linkding-sync").get() + .none { !it.state.isFinished }) + } finally { + app.container.settings.save("", null, "", true, false) + notifications.cancelAll() + } + } + @Test fun appearsInTextShareTargets() { val context = InstrumentationRegistry.getInstrumentation().targetContext val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" } diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt index 4982362..35dc058 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncNotifierTest.kt @@ -78,6 +78,18 @@ class SyncNotifierTest { 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) + 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) + assertNotNull("Every save while sync cannot run reminds again", active(SyncNotifier.PROBLEM_ID)) + } + @Test fun staleQueueIsShownOncePerOldestEntry() { val notifier = SyncNotifier(context) { now } notifier.checkStale(now - SyncNotifier.STALE_AFTER_MS + 60_000) diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index 4df7205..cd50cfd 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -113,6 +113,7 @@ import org.evsyukov.shareding.network.BookmarkTitles import org.evsyukov.shareding.network.Urls import org.evsyukov.shareding.sync.NotificationAccess import org.evsyukov.shareding.sync.SyncNotifier +import org.evsyukov.shareding.sync.SyncProblem class MainActivity : ComponentActivity() { private val container get() = (application as ShareDingApplication).container @@ -294,9 +295,11 @@ private fun ShareDingScreen(container: AppContainer) { @Composable private fun QueueSyncStatus(settings: Settings, workInfos: List, queueCount: Int) { val active = workInfos.filter { !it.state.isFinished } + val blocked = SyncProblem.blocking(settings) val status = when { active.any { it.state == WorkInfo.State.RUNNING } -> "Syncing…" - queueCount > 0 && !settings.configured -> "Set up linkding in Settings to sync" + blocked == SyncProblem.NOT_CONFIGURED && queueCount > 0 -> "Set up linkding in Settings to sync" + blocked != null && blocked != SyncProblem.NOT_CONFIGURED -> "Sync stopped · ${blocked.title}" queueCount > 0 && active.isNotEmpty() -> "Waiting for network or retry" else -> "Ready to sync" } @@ -524,9 +527,9 @@ private fun AddBookmarkScreen(container: AppContainer, availableTags: List NOT_CONFIGURED + else -> entries.firstOrNull { it.name == settings.syncStopped } + } + + /** Toast after a save; tells the user at once when the link will not be sent. */ + fun savedMessage(inserted: Boolean, blocked: SyncProblem?): String { + val saved = if (inserted) "Saved to queue" else "Already in queue" + return when (blocked) { + null -> saved + NOT_CONFIGURED -> "$saved · linkding is not set up" + else -> "$saved · sync is stopped" + } + } + fun of(error: Throwable): SyncProblem? = when { error is ApiException && (error.code == 401 || error.code == 403) -> AUTH error is ApiException && error.code == 404 -> NOT_FOUND @@ -71,6 +95,11 @@ class SyncNotifier(private val context: Context, private val now: () -> Long = S if (post(PROBLEM_ID, problem.title, 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 resolved() { manager.cancel(PROBLEM_ID) manager.cancel(STALE_ID) diff --git a/app/src/test/java/org/evsyukov/shareding/sync/SyncProblemTest.kt b/app/src/test/java/org/evsyukov/shareding/sync/SyncProblemTest.kt index 0423333..f233e5e 100644 --- a/app/src/test/java/org/evsyukov/shareding/sync/SyncProblemTest.kt +++ b/app/src/test/java/org/evsyukov/shareding/sync/SyncProblemTest.kt @@ -1,6 +1,7 @@ package org.evsyukov.shareding.sync import java.io.IOException +import org.evsyukov.shareding.data.Settings import org.evsyukov.shareding.network.ApiException import org.evsyukov.shareding.network.Urls import org.junit.Assert.assertEquals @@ -16,6 +17,27 @@ class SyncProblemTest { .exceptionOrNull()!!)) } + @Test fun namesWhatKeepsSavedLinksFromBeingSent() { + val ready = Settings(serverUrl = "https://linkding.example", hasToken = true) + assertNull(SyncProblem.blocking(ready)) + assertEquals(SyncProblem.NOT_CONFIGURED, SyncProblem.blocking(Settings())) + assertEquals(SyncProblem.NOT_CONFIGURED, SyncProblem.blocking(ready.copy(hasToken = false))) + assertEquals(SyncProblem.AUTH, SyncProblem.blocking(ready.copy(syncStopped = "AUTH"))) + // Without a server there is nothing to resume, whatever stopped sync before. + assertEquals(SyncProblem.NOT_CONFIGURED, + SyncProblem.blocking(Settings(syncStopped = "AUTH"))) + } + + @Test fun savedToastSaysWhenTheLinkWillNotBeSent() { + assertEquals("Saved to queue", SyncProblem.savedMessage(true, null)) + assertEquals("Already in queue", SyncProblem.savedMessage(false, null)) + assertEquals("Saved to queue · linkding is not set up", + SyncProblem.savedMessage(true, SyncProblem.NOT_CONFIGURED)) + assertEquals("Saved to queue · sync is stopped", SyncProblem.savedMessage(true, SyncProblem.AUTH)) + assertEquals("Already in queue · sync is stopped", + SyncProblem.savedMessage(false, SyncProblem.NOT_FOUND)) + } + @Test fun leavesTransientErrorsToRetries() { assertNull(SyncProblem.of(ApiException(503, "busy"))) assertNull(SyncProblem.of(ApiException(400, "bad bookmark"))) From 72919452218ea51e91c328185bf6ee905db6e4e1 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:33:47 +0300 Subject: [PATCH 4/7] feat(sync)!: apply bookmark defaults when a link is sent Default tags, unread, and archive are read from the current settings for every POST, so changing them affects links that are already queued, including links saved before the server was set up. A queue entry keeps only the tags typed for it. BREAKING CHANGE: Room schema version 3 drops the unread and archived columns. Links already queued keep the default tags they were saved with and take the flags from the current settings. Refs #35 --- .../org/evsyukov/shareding/QueueUiTest.kt | 7 +- .../org/evsyukov/shareding/SyncWorkerTest.kt | 65 +++++++++++++++++++ .../shareding/data/BookmarkMigrationTest.kt | 40 +++++++++++- .../org/evsyukov/shareding/MainActivity.kt | 8 +-- .../org/evsyukov/shareding/ShareActivity.kt | 5 +- .../shareding/ShareDingApplication.kt | 2 +- .../org/evsyukov/shareding/data/Bookmark.kt | 26 +++++++- .../evsyukov/shareding/network/LinkdingApi.kt | 11 ++-- .../org/evsyukov/shareding/sync/SyncWorker.kt | 4 +- .../shareding/network/LinkdingApiTest.kt | 19 +++--- 10 files changed, 156 insertions(+), 31 deletions(-) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt index d4c729b..9dba1c9 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt @@ -277,7 +277,8 @@ class QueueUiTest { @Test fun fullScreenFormSavesBookmarkLocally() { val app = compose.activity.application as ShareDingApplication val url = "https://example.com/manual-${System.nanoTime()}" - app.container.settings.save("", null, "", true, false) + // Default tags join the link when it is sent; the queue keeps only what was typed here. + app.container.settings.save("", null, "reading", true, false) runBlocking { withContext(Dispatchers.IO) { app.container.db.clearAllTables() } } compose.onNodeWithText("Add bookmark").performClick() compose.onNodeWithText("URL").performTextInput(url) @@ -291,6 +292,7 @@ class QueueUiTest { assertEquals("local, work notes", runBlocking { app.container.db.bookmarks().findByUrl(url)?.tags }) assertEquals("Manual title", runBlocking { app.container.db.bookmarks().findByUrl(url)?.title }) assertEquals(true, runBlocking { app.container.db.bookmarks().findByUrl(url)?.sendTitle }) + app.container.settings.save("", null, "", true, false) } @Test fun fetchPageDetailsFillsTitleAndDescriptionButNotTags() { @@ -450,7 +452,8 @@ class QueueUiTest { compose.waitUntil(5_000) { runBlocking { app.container.db.bookmarks().findByUrl(url) != null } } - assertEquals("reading, research", runBlocking { + // The default tag "reading" is added when the link is sent, not stored with it. + assertEquals("research", runBlocking { app.container.db.bookmarks().findByUrl(url)?.tags }) } diff --git a/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt index be9c9a1..360220d 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/SyncWorkerTest.kt @@ -228,6 +228,71 @@ class SyncWorkerTest { } } + @Test fun linkSavedBeforeServerSetupIsSentWithCurrentDefaults() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val tls = AndroidTestTls(InstrumentationRegistry.getInstrumentation().context) + val app = context.applicationContext as ShareDingApplication + WorkManager.getInstance(context).cancelUniqueWork("linkding-sync").result.get() + val dao = app.container.db.bookmarks() + withContext(Dispatchers.IO) { app.container.db.clearAllTables() } + MockWebServer().apply { tls.start(this) }.use { server -> + try { + app.container.settings.save("", null, "old", true, false) + dao.insert(Bookmark(url = "https://example.com/before-setup")) + dao.insert(Bookmark(url = "https://example.com/from-form", tags = "work, reading")) + app.container.settings.save(tls.url(server), "test-token", "reading", false, true) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + TestListenableWorkerBuilder(context).build().doWork() + assertEquals(0, dao.count()) + server.takeRequest() + val shared = com.google.gson.JsonParser.parseString(server.takeRequest().body.readUtf8()).asJsonObject + assertEquals(listOf("reading"), shared.getAsJsonArray("tag_names").map { it.asString }) + assertEquals(false, shared.get("unread").asBoolean) + assertEquals(true, shared.get("is_archived").asBoolean) + val form = com.google.gson.JsonParser.parseString(server.takeRequest().body.readUtf8()).asJsonObject + assertEquals(listOf("reading", "work"), form.getAsJsonArray("tag_names").map { it.asString }) + } finally { + app.container.settings.save("", null, "", true, false) + } + } + } + + @Test fun linkStuckOnRejectedTokenIsSentWithDefaultsChangedMeanwhile() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val tls = AndroidTestTls(InstrumentationRegistry.getInstrumentation().context) + val app = context.applicationContext as ShareDingApplication + WorkManager.getInstance(context).cancelUniqueWork("linkding-sync").result.get() + val dao = app.container.db.bookmarks() + withContext(Dispatchers.IO) { app.container.db.clearAllTables() } + MockWebServer().apply { tls.start(this) }.use { server -> + try { + app.container.settings.save(tls.url(server), "revoked", "old", true, false) + dao.insert(Bookmark(url = "https://example.com/stuck")) + server.enqueue(MockResponse().setResponseCode(401).setBody("Invalid token.")) + TestListenableWorkerBuilder(context).build().doWork() + assertEquals(1, dao.count()) + + app.container.settings.save(tls.url(server), "fixed", "reading, later", false, true) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + TestListenableWorkerBuilder(context).build().doWork() + assertEquals(0, dao.count()) + server.takeRequest() + server.takeRequest() + val post = server.takeRequest() + assertEquals("Token fixed", post.getHeader("Authorization")) + val body = com.google.gson.JsonParser.parseString(post.body.readUtf8()).asJsonObject + assertEquals(listOf("reading", "later"), body.getAsJsonArray("tag_names").map { it.asString }) + assertEquals(false, body.get("unread").asBoolean) + assertEquals(true, body.get("is_archived").asBoolean) + } finally { + app.container.settings.save("", null, "", true, false) + } + } + } + @Test fun rejectedTokenStopsSyncUntilTheUserResumes() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val tls = AndroidTestTls(InstrumentationRegistry.getInstrumentation().context) diff --git a/app/src/androidTest/java/org/evsyukov/shareding/data/BookmarkMigrationTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/data/BookmarkMigrationTest.kt index 212f800..f856c53 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/data/BookmarkMigrationTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/data/BookmarkMigrationTest.kt @@ -28,7 +28,7 @@ class BookmarkMigrationTest { legacy.version = 1 } val upgraded = Room.databaseBuilder(context, AppDatabase::class.java, name) - .addMigrations(AppDatabase.MIGRATION_1_2).build() + .addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3).build() try { val entry = upgraded.bookmarks().batch(1).single() assertEquals(7L, entry.id) @@ -37,7 +37,6 @@ class BookmarkMigrationTest { assertEquals("Summary", entry.description) assertEquals("Notes", entry.notes) assertEquals("inbox", entry.tags) - assertEquals(true, entry.unread) assertEquals("failed", entry.status) assertEquals(2, entry.attempts) assertEquals("HTTP 400", entry.lastError) @@ -51,4 +50,41 @@ class BookmarkMigrationTest { context.deleteDatabase(name) } } + + @Test fun upgradeDropsStoredFlagsAndKeepsTheQueue() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "migration-${System.nanoTime()}.db" + context.openOrCreateDatabase(name, 0, null).use { legacy -> + legacy.execSQL("""CREATE TABLE bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, url TEXT NOT NULL, + title TEXT NOT NULL, sendTitle INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL, notes TEXT NOT NULL, + tags TEXT NOT NULL, unread INTEGER NOT NULL, archived INTEGER NOT NULL, + metadataFetched INTEGER NOT NULL, createdAt INTEGER NOT NULL, + status TEXT NOT NULL, attempts INTEGER NOT NULL, lastAttemptAt INTEGER, + lastError TEXT)""") + legacy.execSQL("CREATE UNIQUE INDEX index_bookmarks_url ON bookmarks(url)") + legacy.execSQL("INSERT INTO bookmarks VALUES (7, 'https://example.com/queued', 'Manual', 1, 'Summary', 'Notes', 'inbox, work', 0, 1, 1, 123, 'failed', 2, 456, 'HTTP 401')") + legacy.version = 2 + } + val upgraded = Room.databaseBuilder(context, AppDatabase::class.java, name) + .addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3).build() + try { + assertEquals(Bookmark(id = 7, url = "https://example.com/queued", title = "Manual", + sendTitle = true, description = "Summary", notes = "Notes", tags = "inbox, work", + metadataFetched = true, createdAt = 123, status = "failed", attempts = 2, + lastAttemptAt = 456, lastError = "HTTP 401"), upgraded.bookmarks().batch(1).single()) + val columns = mutableListOf() + upgraded.openHelper.readableDatabase.query("PRAGMA table_info(bookmarks)").use { cursor -> + while (cursor.moveToNext()) columns += cursor.getString(cursor.getColumnIndexOrThrow("name")) + } + assertEquals(false, "unread" in columns || "archived" in columns) + assertEquals(-1L, upgraded.bookmarks().insert(Bookmark(url = "https://example.com/queued"))) + // The rebuilt table keeps counting ids after the migrated rows. + assertEquals(true, upgraded.bookmarks().insert(Bookmark(url = "https://example.com/next")) > 7) + } finally { + upgraded.close() + context.deleteDatabase(name) + } + } } diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index cd50cfd..3c0a12d 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -517,14 +517,12 @@ private fun AddBookmarkScreen(container: AppContainer, availableTags: List val server = tls.server server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) - val bookmark = Bookmark(url = "https://example.com", title = "Example", tags = "one, two words", - unread = true, archived = false) - tls.api().send(server.url("/").toString(), "secret", bookmark) + val bookmark = Bookmark(url = "https://example.com", title = "Example", tags = "two words, one") + tls.api().send(server.url("/").toString(), "secret", bookmark, + Settings(defaultTags = "one", unread = true, archived = false)) val request = server.takeRequest() assertEquals("/api/bookmarks/", request.path) assertEquals("Token secret", request.getHeader("Authorization")) @@ -44,7 +45,7 @@ class LinkdingApiTest { description = "Описание — déjà vu", ) - tls.api().send(server.url("/").toString(), "secret", bookmark) + tls.api().send(server.url("/").toString(), "secret", bookmark, Settings()) val request = server.takeRequest() assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")) @@ -67,7 +68,7 @@ class LinkdingApiTest { .addHeader("Location", destination.url("/stolen"))) try { originalTls.api().send(original.url("/").toString(), "secret", - Bookmark(url = "https://example.com")) + Bookmark(url = "https://example.com"), Settings()) fail("Redirect must not count as success") } catch (error: ApiException) { assertEquals(302, error.code) @@ -132,7 +133,7 @@ class LinkdingApiTest { TlsMockServer().use { tls -> tls.server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) val bookmark = Bookmark(url = "https://example.com", title = "first line\n" + "x".repeat(600)) - tls.api().send(tls.server.url("/").toString(), "secret", bookmark) + tls.api().send(tls.server.url("/").toString(), "secret", bookmark, Settings()) val body = JsonParser.parseString(tls.server.takeRequest().body.readUtf8()).asJsonObject assertFalse(body.has("title")) } @@ -142,7 +143,7 @@ class LinkdingApiTest { TlsMockServer().use { tls -> tls.server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) tls.api().send(tls.server.url("/").toString(), "secret", - Bookmark(url = "https://example.com", title = " \n\t ", sendTitle = true)) + Bookmark(url = "https://example.com", title = " \n\t ", sendTitle = true), Settings()) val body = JsonParser.parseString(tls.server.takeRequest().body.readUtf8()).asJsonObject assertFalse(body.has("title")) } @@ -153,7 +154,7 @@ class LinkdingApiTest { tls.server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) val title = "a".repeat(511) + "😀" + "b" + "\nextra line" tls.api().send(tls.server.url("/").toString(), "secret", - Bookmark(url = "https://example.com", title = title, sendTitle = true)) + Bookmark(url = "https://example.com", title = title, sendTitle = true), Settings()) val body = JsonParser.parseString(tls.server.takeRequest().body.readUtf8()).asJsonObject val sentTitle = body.get("title").asString assertEquals(512, sentTitle.codePointCount(0, sentTitle.length)) @@ -196,7 +197,7 @@ class LinkdingApiTest { MockWebServer().use { server -> val error = runCatching { LinkdingApi().send(server.url("/").toString(), "secret", - Bookmark(url = "http://example.com/article")) + Bookmark(url = "http://example.com/article"), Settings()) }.exceptionOrNull() assertEquals("Use an HTTPS linkding server URL", error?.message) assertEquals(0, server.requestCount) From 49732f91728dd13afe857f2ff8d94466fe695873 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:40:40 +0300 Subject: [PATCH 5/7] feat(share)!: publish a Direct Share target and require Android 10 ShareDing now appears in the Direct Share row of the share sheet. The app publishes one long-lived sharing shortcut on start and again after each share, and still registers a single ACTION_SEND target. BREAKING CHANGE: the minimum version is Android 10 (API 29), the first one with sharing shortcuts. Installs on Android 9 stay on 0.2.x. Refs #35 --- app/build.gradle.kts | 2 +- .../org/evsyukov/shareding/ShareIntentTest.kt | 14 +++++++ app/src/main/AndroidManifest.xml | 3 ++ .../org/evsyukov/shareding/ShareActivity.kt | 37 ++++++++++++++++++- .../shareding/ShareDingApplication.kt | 5 +++ app/src/main/res/xml/shortcuts.xml | 8 ++++ 6 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/xml/shortcuts.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3166964..e10f360 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,7 +57,7 @@ android { defaultConfig { applicationId = "org.evsyukov.shareding" - minSdk = 28 + minSdk = 29 targetSdk = 36 versionCode = 22 versionName = "0.2.4-rc.1" diff --git a/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt index 9eeec82..df123fe 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import androidx.core.content.pm.ShortcutManagerCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkManager @@ -33,6 +34,19 @@ class ShareIntentTest { return null } + @Test fun appStartPublishesDirectShareTarget() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var categories = emptyList>() + repeat(40) { + categories = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC) + .mapNotNull { it.categories } + if (categories.isNotEmpty()) return@repeat + Thread.sleep(50) + } + // The category ties the shortcut to the share-target declared in res/xml/shortcuts.xml. + assertEquals(listOf(setOf(DirectShare.CATEGORY)), categories) + } + @Test fun shareWhileSyncCannotRunSavesTheLinkAndNotifiesWithoutSchedulingWork() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val app = context.applicationContext as ShareDingApplication diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0183760..73af0a8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,9 @@ + + + + + + + + From 4d706cb0ca3e8ee28459c976ddcbb77fb581d3fc Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:41:22 +0300 Subject: [PATCH 6/7] docs: describe sync gating, send-time defaults, Direct Share, and Android 10 Refs #35 --- README.md | 12 ++++----- docs/specs/share-to-linkding.md | 27 ++++++++++++------- .../android/en-US/full_description.txt | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c550ee1..62e31f1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Android CI](https://github.com/juev/shareding/actions/workflows/android.yml/badge.svg)](https://github.com/juev/shareding/actions/workflows/android.yml) [![Latest release](https://img.shields.io/github/v/release/juev/shareding?include_prereleases)](https://github.com/juev/shareding/releases) -ShareDing is an Android share target for [linkding](https://github.com/sissbruecker/linkding). It saves links from other apps to a local queue, then sends them to your linkding server. It requires Android 9 (API 28) or newer. +ShareDing is an Android share target for [linkding](https://github.com/sissbruecker/linkding). It saves links from other apps to a local queue, then sends them to your linkding server. It requires Android 10 (API 29) or newer. [Releases](https://github.com/juev/shareding/releases) · [Quick start](#quick-start) · [Screenshots](#screenshots) · [Privacy](docs/privacy.md) · [Contributing](#contributing) · [License](#license) @@ -20,7 +20,7 @@ Saving a link should not depend on whether your linkding server is reachable at 3. WorkManager runs sync when a network is available. ShareDing checks the configured linkding server itself, so a server reachable through LAN or VPN does not need public internet access. 4. Before sending a link from a known URL shortener such as `t.co`, `bit.ly`, or `vk.cc`, ShareDing asks the shortener where it leads and sends that address instead, noting the short link in the bookmark notes. It never opens the target site, so a blocked destination does not matter; if the shortener cannot be reached, the short link is sent as is. After linkding accepts the bookmark, ShareDing removes it from the queue. Connection and server errors leave it queued for another attempt. Retries use exponential backoff starting at 30 seconds; Android may run them later than the scheduled time. -WorkManager also checks for queued links about every 30 minutes when a suitable network is available. This check reads the local queue and restores a missing sync job; it does not contact linkding or reset an existing retry delay. Android may postpone the check to save battery. It can run after the app process has been removed or the phone has rebooted, without opening ShareDing. A user force-stop pauses background work until the app is opened or used from the Share menu. +While a server is set up and sync is not stopped, WorkManager also checks for queued links about every 30 minutes when a suitable network is available. This check reads the local queue and restores a missing sync job; it does not contact linkding or reset an existing retry delay. Android may postpone the check to save battery. It can run after the app process has been removed or the phone has rebooted, without opening ShareDing. A user force-stop pauses background work until the app is opened or used from the Share menu. The queue survives app restarts and device reboots. Delivery is at least once: if linkding accepts a request but the response is lost, ShareDing may send the same URL again. Linkding updates an existing bookmark with that URL. @@ -40,12 +40,12 @@ These screens were captured on an Android 16 emulator with an example link in th 1. Install the APK using [Obtainium](https://github.com/ImranR98/Obtainium) or download it from [Releases](https://github.com/juev/shareding/releases). See [Install and update](#install-and-update) for details. 2. In Settings, enter your HTTPS linkding server URL and API token, then tap **Save settings** at the bottom. Use **Test Connection** to check the server. If you previously configured an HTTP server URL, change it to HTTPS to resume sync; your queued bookmarks remain on the device. -3. Open a browser's Share menu and select **ShareDing**. It returns to the browser after a short confirmation without opening the main app screen. To add tags or a description, tap `+` in Queue and enter the link manually; the paste button in the URL field inserts the first link from the clipboard. The full-screen form accepts a URL, title, description, and tags; **Fetch page details** fills an empty title and description from the page; tags stay as you enter them. +3. Open a browser's Share menu and select **ShareDing**, either in the app row or in the Direct Share row above it. It returns to the browser after a short confirmation without opening the main app screen. To add tags or a description, tap `+` in Queue and enter the link manually; the paste button in the URL field inserts the first link from the clipboard. The full-screen form accepts a URL, title, description, and tags; **Fetch page details** fills an empty title and description from the page; tags stay as you enter them. 4. Check Queue for waiting, sending, or failed bookmarks and the last sync result and time. Tap a card to inspect and copy its full title, URL, and error. Failed entries have a **Retry** action; the **Sync now** button in Queue restarts sync immediately when a network is available and, with an empty queue, checks the server so the last sync result stays current. Removing an entry requires confirmation and restarts sync with the remaining links. -If sync hits a problem that retrying cannot fix, such as a rejected API token, or links wait longer than a day, ShareDing shows a notification. On Android 13 and newer it asks for the notification permission the first time you open the app; you can turn alerts on later in Settings. Without notifications, sync works the same and errors stay visible in Queue. +If sync hits a problem that retrying cannot fix, such as a rejected API token, ShareDing stops sync and shows a notification. Sync starts again when you save settings, tap **Sync now**, or tap **Retry**. Until then, and while no server is set up, saving a link shows the reason in the confirmation and in a notification, and no background work runs. Links that wait longer than a day also raise a notification. On Android 13 and newer it asks for the notification permission the first time you open the app; you can turn alerts on later in Settings. Without notifications, sync works the same and errors stay visible in Queue. -Settings also lets you set default tags and choose whether new bookmarks are marked unread or archived. Enter multiple tags with commas, for example `reading, work notes`, then tap **Save settings**. With a configured connection, existing linkding tags appear as suggestions while you type in Settings or a bookmark form. You can still enter new tags and save while the server is offline. +Settings also lets you set default tags and choose whether bookmarks are marked unread or archived. These defaults are applied when a link is sent, so changing them affects links already in the queue. Enter multiple tags with commas, for example `reading, work notes`, then tap **Save settings**. With a configured connection, existing linkding tags appear as suggestions while you type in Settings or a bookmark form. You can still enter new tags and save while the server is offline. 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. @@ -59,7 +59,7 @@ For automatic update checks, use [Obtainium](https://github.com/ImranR98/Obtaini For manual installation, open [Releases](https://github.com/juev/shareding/releases) on your phone, download the latest APK, and confirm the installation. If Android asks for permission to install apps from this source, grant it to your browser or file manager. To update, download the newer APK and install it over the current version. -You can also install the downloaded APK over USB with `adb install -r /path/to/downloaded.apk`. All methods require Android 9 or newer. Release APKs use the same signing key, which Android requires for updates. Debug APKs use a different key. If you already installed a debug build, uninstall it before installing the release build. Uninstalling deletes its settings and local queue. +You can also install the downloaded APK over USB with `adb install -r /path/to/downloaded.apk`. All methods require Android 10 or newer. Release APKs use the same signing key, which Android requires for updates. Debug APKs use a different key. If you already installed a debug build, uninstall it before installing the release build. Uninstalling deletes its settings and local queue. ## Build diff --git a/docs/specs/share-to-linkding.md b/docs/specs/share-to-linkding.md index cf83030..8033b7a 100644 --- a/docs/specs/share-to-linkding.md +++ b/docs/specs/share-to-linkding.md @@ -1,23 +1,23 @@ # Sending links to linkding on Android -Status: R1–R23 and R25–R30 implemented with automated coverage on Android 9 and 16. R24 (queue editing rules) was removed on 2026-10-01 together with the queue editor and the "ShareDing…" form Share target; R23 and R29 now state what remains. WorkManager may delay recovery under Android power restrictions. A real VPN has not been tested manually. +Status: R1–R23 and R25–R30 implemented with automated coverage on Android 9 and 16. R24 (queue editing rules) was removed on 2026-10-01 together with the queue editor and the "ShareDing…" form Share target; R23 and R29 now state what remains. R31–R33 (sync stopped by persistent errors, defaults applied at send time, Direct Share) and the Android 10 minimum were added on 2026-10-05 with automated coverage on Android 16; they changed R3, R8, R18, R19, and R28. WorkManager may delay recovery under Android power restrictions. A real VPN has not been tested manually. -Sources: the user's requirements on 2026-09-26 through 2026-09-30, including the decision to send titles from the Add Bookmark form and omit titles from Share and the 2026-10-01 decision to remove queue editing and the form Share target, the agreed RFCs, the iOS app in the sibling `share` repository, the observed Chrome share Intent on Android 16, and the [linkding API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/src/content/docs/api.md). +Sources: the user's requirements on 2026-09-26 through 2026-10-05, including the decision to send titles from the Add Bookmark form and omit titles from Share and the 2026-10-01 decision to remove queue editing and the form Share target, the agreed RFCs, the iOS app in the sibling `share` repository, the observed Chrome share Intent on Android 16, and the [linkding API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/src/content/docs/api.md). ## Purpose and scope -The app accepts a link from Android's Share menu or its add form, saves it on the device, and sends it to the configured linkding server. Saving does not depend on network availability or server settings. Android 9 (API 28) and newer are supported. +The app accepts a link from Android's Share menu or its add form, saves it on the device, and sends it to the configured linkding server. Saving does not depend on network availability or server settings. Android 10 (API 29) and newer are supported. ## Requirements - 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. +- 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. - 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. -- R8. Set `minSdk` to 28. Verify behavior on Android 9 and a current Android version. +- R8. Set `minSdk` to 29, the first Android version with sharing shortcuts (R33). Verify behavior on a current Android version. - R9. Require `https://` for the linkding server URL before sending its API token through any distribution channel. Keep an existing `http://` server URL visible and editable; Test Connection and background sync must fail with a clear HTTPS requirement until it is changed. A queued bookmark remains available for later delivery. HTTPS uses normal certificate validation; redirects to another origin must not receive the token. HTTP(S) bookmark URLs and HTTP page metadata fetches remain supported without the linkding token. - R10. Keep one visible Save settings action while editing any Settings section. After a successful save, hide the keyboard and show confirmation; on failure, keep the draft available for correction. Explain comma-separated tag entry with an example in Settings and Add Bookmark. When saved server credentials are available, fetch all pages of existing linkding tags and suggest matching names in both forms. Suggestions must not prevent entering a new tag or saving while the server is unreachable. Default tags are saved locally; Add Bookmark saves the entered tags with the queued bookmark. - R11. The Add Bookmark metadata action appears after title, description, and tags. It fills empty fields from available HTML title, description, and keyword tags without overwriting text already entered. A failed fetch leaves the draft editable; a result for an outdated URL is ignored. @@ -27,8 +27,8 @@ The app accepts a link from Android's Share menu or its add form, saves it on th - R15. Repeated saves while sync is waiting must reuse that pending WorkManager job. If sync is running, schedule at most one successor so a link saved after its final queue read still gets processed. Automatic saves and recovery do not cancel in-flight delivery; manual actions follow R19. - R16. Keep the title supplied in `EXTRA_TITLE` or `EXTRA_SUBJECT` locally for inspection, including multiline text. Drop a title that is only the shared link: any lone HTTP(S) URL, the link without scheme or `www.`, its prefix shortened with an ellipsis, or the link split by whitespace as in Mastodon timelines. Omit the title field from linkding requests for shared bookmarks; linkding determines its own title. Send a nonblank title saved in the Add Bookmark form, including details explicitly fetched into that form. Collapse whitespace into one line and limit the sent title to 512 Unicode code points without splitting surrogate pairs. Omit blank titles. Background sync does not fetch bookmarked pages. Explicitly entered descriptions are preserved. - R17. Preserve Unicode text and encoded URLs from Android shares through local storage and the UTF-8 linkding JSON request. For fetched HTML, use the HTTP charset when supplied, otherwise detect a declared HTML charset and fall back to UTF-8. Do not decode or normalize URL escapes as text. -- R18. Register one persistent recovery check with a 30-minute WorkManager interval and a CONNECTED network constraint that accepts LAN and VPN networks without public internet validation. It reads the local queue and, only when links and server settings are present, restores a missing one-time sync request. It does not send links, replace pending or running work, append a successor, or shorten retry backoff. App startup and new-network callbacks also skip normal sync when the queue or connection settings are empty. Android may delay the check to save power; no app screen or continuously running service is required. -- R19. Queue Sync now, Retry, saved connection settings, and confirmed deletion replace existing one-time sync work or create it if missing. 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. +- R18. While sync can run (R31), register one persistent recovery check with a 30-minute WorkManager interval and a CONNECTED network constraint that accepts LAN and VPN networks without public internet validation. It reads the local queue and, only when links and server settings are present, restores a missing one-time sync request. It does not send links, replace pending or running work, append a successor, or shorten retry backoff. App startup and new-network callbacks also skip normal sync when the queue is empty or sync cannot run. When sync cannot run, the recovery check is cancelled; it is registered again when settings are saved or sync resumes. Android may delay the check to save power; no app screen or continuously running service is required. +- 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. @@ -36,9 +36,12 @@ The app accepts a link from Android's Share menu or its add form, saves it on th - 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. On Android 13+, request the notification permission once, the first time the main screen opens. Settings shows whether sync alerts are on; its Turn on action repeats the permission request while Android allows it and otherwise opens the system notification 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. 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. Settings shows whether sync alerts are on; its Turn on action repeats the permission request while Android allows it and otherwise opens the system notification 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. +- R32. Default tags, unread, and archive are read from the current settings for every POST, so a change applies to all queued links, including links saved before the server was set up or while sync was stopped. A queued entry stores only the tags entered for it in Add Bookmark; they are sent after the default tags without duplicates. +- R33. Publish one long-lived sharing shortcut for `text/plain`, so ShareDing appears in the Direct Share row. Publish it on app start and again after each share. Selecting it saves the link exactly as the R1 target does. The shortcut also appears in the launcher menu of the app icon and opens the app there: Android does not keep a shortcut that is excluded from the launcher as a dynamic one. ## Invariants and compatibility @@ -47,9 +50,10 @@ The app accepts a link from Android's Share menu or its add form, saves it on th - Delivery has at-least-once semantics. If the server accepts a POST but the response is lost, the request may be repeated. Current linkding updates an existing bookmark with the same URL. - Missing server URL or token does not prevent local saving. The queue remains available for delivery after setup. - A saved page URL may use HTTP or HTTPS regardless of the linkding server URL scheme. -- Editing tag defaults or loading tag suggestions does not change already queued bookmarks. A missing connection does not prevent local tag entry or saving bookmark defaults. +- Bookmark defaults belong to settings, not to queued entries (R32). Loading tag suggestions does not change queued bookmarks. A missing connection does not prevent local tag entry or saving bookmark defaults. - The app has no proxy transport. Proxy support was removed because HTTP proxy Basic credentials could only be sent without TLS; a system VPN is the supported way to reach a server through another route. - Room version 2 adds a `sendTitle` flag. Version 1 entries retain every existing field, with `sendTitle = false` because their title source was not recorded. Their titles stay visible locally; linkding extracts the title on delivery. New nonblank form titles set the flag; Share titles do not. +- Room version 3 drops the `unread` and `archived` columns. Every other field of a queued entry is kept. Entries queued before the upgrade keep the default tags stored with them and take both flags from the current settings. ## Design decisions @@ -87,7 +91,10 @@ For R16–R17, Share titles serve as local labels. A persisted flag distinguishe - R28: Classify errors, show and deduplicate problem and waiting notifications, clear them after success, and show the Settings state. Deny the permission twice on Android 13+ and confirm Turn on first asks again, then opens system settings; granting it outside the app switches the state to On on return. Covered by `SyncProblemTest`, `NotificationAccessTest`, `SyncNotifierTest`, and `QueueUiTest.settingsShowGrantedNotificationAlertsAsOn`; 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`. +- R32: Queue a shared link and a form link with its own tags, change the defaults, and confirm the POST bodies carry the new tags and flags; repeat for a link stuck on a rejected token. Upgrade version 1 and version 2 databases and confirm the queue is intact and the two columns are gone. Covered by `SyncWorkerTest`, `LinkdingApiTest`, `QueueUiTest`, and `BookmarkMigrationTest`. +- R33: After app start the dynamic shortcut carries the share-target category; covered by `ShareIntentTest.appStartPublishesDirectShareTarget`. On an API 36 emulator the system share sheet showed ShareDing in the Direct Share row and selecting it queued the link; this was checked manually. Automated checks: `./gradlew assembleDebug assembleRelease testDebugUnitTest testReleaseUnitTest lintDebug lintRelease connectedDebugAndroidTest`. URL, metadata, and linkding API cases are covered in `app/src/test/java/org/evsyukov/shareding/network/`; Room queue, Share Intent, worker, scheduler, and UI cases are covered in `app/src/androidTest/java/org/evsyukov/shareding/`. -Manual network checks: On Android 9, turn off Wi-Fi and mobile data, share a URL, close the app, turn Wi-Fi on, then confirm the POST reaches a test server and the queue empties. To check LAN access without public internet, disable public internet validation in the emulator, confirm that `NET_CAPABILITY_VALIDATED` is absent, and send to a reachable linkding server. To check backoff, make the test server return 503 for the first POST and 201 for the next; the entry must remain queued until the second response. +Manual network checks: Turn off Wi-Fi and mobile data, share a URL, close the app, turn Wi-Fi on, then confirm the POST reaches a test server and the queue empties. To check LAN access without public internet, disable public internet validation in the emulator, confirm that `NET_CAPABILITY_VALIDATED` is absent, and send to a reachable linkding server. To check backoff, make the test server return 503 for the first POST and 201 for the next; the entry must remain queued until the second response. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index a62ee40..326eb90 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -14,4 +14,4 @@ Network connections: * If you tap Fetch page details in the Add Bookmark form, ShareDing requests the bookmarked page from your device to read its title and description. The shortener and page requests do not include your linkding API token. -ShareDing requires Android 9 or newer and your own linkding server. Enter its HTTPS URL and API token in Settings. To reach a server through another route, use a VPN on your device. +ShareDing requires Android 10 or newer and your own linkding server. Enter its HTTPS URL and API token in Settings. To reach a server through another route, use a VPN on your device. From 77eaeb03296a961dd8b4c56cddef511d08510cdb Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Mon, 5 Oct 2026 11:45:11 +0300 Subject: [PATCH 7/7] fix(settings): reject default tags longer than linkding accepts Default tags are no longer merged into a link in the Add Bookmark form, so its length check does not see them. Check them when settings are saved instead. Refs #35 --- app/src/main/java/org/evsyukov/shareding/MainActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index 3c0a12d..e69ad73 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -267,6 +267,8 @@ private fun ShareDingScreen(container: AppContainer) { else SettingsScreen(settings, container.notifier, padding, serverTags, tagLoadError, onSave = { server, token, tags, unread, archived -> if (server.isNotBlank()) Urls.server(server) + // Default tags now reach linkding without passing through the Add Bookmark check. + TagNames.requireValid(tags) withContext(Dispatchers.IO) { container.settings.save(server.trim(), token, tags.trim(), unread, archived) }