diff --git a/README.md b/README.md index 62e31f1..33a23af 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ These screens were captured on an Android 16 emulator with an example link in th 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, 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. +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; to change the answer later, use the system settings of the app. Without notifications, sync works the same and errors stay visible in Queue. 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. diff --git a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt index 9dba1c9..9ca7b56 100644 --- a/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt +++ b/app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt @@ -347,12 +347,11 @@ class QueueUiTest { assertTrue(app.container.settings.state.value.unread) } - @Test fun settingsShowGrantedNotificationAlertsAsOn() { + @Test fun settingsHaveNoNotificationRow() { compose.onNodeWithText("Settings").performClick() compose.onNode(hasScrollAction()).performScrollToNode(hasText("Privacy policy")) - compose.onNodeWithText("Sync problem alerts").assertIsDisplayed() - compose.onNodeWithText("On").assertIsDisplayed() - compose.onNodeWithText("Turn on").assertDoesNotExist() + compose.onNodeWithText("Notifications").assertDoesNotExist() + compose.onNodeWithText("Sync problem alerts").assertDoesNotExist() } @Test fun connectionFailureRemainsVisibleInSettings() { diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index e69ad73..d6d6591 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -8,10 +8,8 @@ import android.text.format.DateUtils import android.util.Log import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme @@ -111,8 +109,6 @@ import org.evsyukov.shareding.data.Settings import org.evsyukov.shareding.network.TagNames 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() { @@ -264,7 +260,7 @@ private fun ShareDingScreen(container: AppContainer) { }, onDelete = { deleteTarget = it }) } - else SettingsScreen(settings, container.notifier, padding, serverTags, tagLoadError, + else SettingsScreen(settings, 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. @@ -438,42 +434,6 @@ private fun QueueScreen(bookmarks: List, padding: PaddingValues, } } -@Composable -private fun NotificationSetting(notifier: SyncNotifier) { - val activity = LocalActivity.current ?: return - var access by remember { mutableStateOf(notifier.access(activity)) } - // The user may change the permission or channel in system settings and come back. - LifecycleResumeEffect(notifier) { - access = notifier.access(activity) - onPauseOrDispose { } - } - val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - access = notifier.access(activity) - } - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { - Column(Modifier.weight(1f)) { - Text("Sync problem alerts") - Text(if (access == NotificationAccess.ON) "On" else "Off", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (access != NotificationAccess.ON) OutlinedButton(onClick = { - if (access == NotificationAccess.REQUEST_PERMISSION) { - notifier.markPermissionRequested() - permission.launch(Manifest.permission.POST_NOTIFICATIONS) - } else { - activity.startActivity(notifier.settingsIntent()) - } - }) { Text("Turn on") } - } - Text(if (access == NotificationAccess.ON) - "ShareDing notifies you when links cannot be delivered or have waited more than a day." - else "Sync keeps working without notifications; problems are shown in Queue.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) -} - /** Reads the clipboard only when called; returns its first HTTP(S) link. */ private fun clipboardUrl(context: Context): String? { val clip = context.getSystemService(ClipboardManager::class.java)?.primaryClip ?: return null @@ -656,7 +616,7 @@ internal fun TagInput(value: String, onValueChange: (String) -> Unit, label: Str } @Composable -private fun SettingsScreen(settings: Settings, notifier: SyncNotifier, padding: PaddingValues, +private fun SettingsScreen(settings: Settings, padding: PaddingValues, availableTags: List, tagLoadError: Boolean, onSave: suspend (String, String?, String, Boolean, Boolean) -> Unit, onTest: suspend (String, String) -> Unit, @@ -750,9 +710,6 @@ private fun SettingsScreen(settings: Settings, notifier: SyncNotifier, padding: SwitchRow("Archive", archived) { archived = it; saveStatus = null } } } - item { - SettingsGroup("Notifications") { NotificationSetting(notifier) } - } item { SettingsGroup("About") { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, 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 be1d536..9652799 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt @@ -1,7 +1,6 @@ package org.evsyukov.shareding.sync import android.Manifest -import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -9,8 +8,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build -import android.provider.Settings -import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -60,26 +57,10 @@ enum class SyncProblem(val title: String) { } } -/** What the Settings switch for sync alerts should do next. */ -enum class NotificationAccess { ON, REQUEST_PERMISSION, OPEN_SETTINGS; - - companion object { - /** - * Android shows the permission dialog only until the user denies it twice, so after that the - * app has to send the user to the system notification settings instead. - */ - fun of(needsPermission: Boolean, granted: Boolean, enabled: Boolean, requestedBefore: Boolean, - showRationale: Boolean): NotificationAccess = when { - needsPermission && !granted -> - if (!requestedBefore || showRationale) REQUEST_PERMISSION else OPEN_SETTINGS - !enabled -> OPEN_SETTINGS - else -> ON - } - - /** The app asks on its own only once; later requests come from the Settings switch. */ - fun askOnLaunch(needsPermission: Boolean, granted: Boolean, requestedBefore: Boolean): Boolean = - needsPermission && !granted && !requestedBefore - } +object NotificationAccess { + /** The app asks once; after that the user changes the answer in the system settings. */ + fun askOnLaunch(needsPermission: Boolean, granted: Boolean, requestedBefore: Boolean): Boolean = + needsPermission && !granted && !requestedBefore } /** @@ -120,23 +101,10 @@ class SyncNotifier(private val context: Context, private val now: () -> Long = S } } - fun access(activity: Activity): NotificationAccess = NotificationAccess.of( - needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, - granted = hasPermission(), - enabled = enabled(), - requestedBefore = prefs.getBoolean(KEY_PERMISSION_REQUESTED, false), - showRationale = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ActivityCompat.shouldShowRequestPermissionRationale(activity, - Manifest.permission.POST_NOTIFICATIONS), - ) - /** Notifications are allowed for the app and the sync channel has not been turned off. */ private fun enabled(): Boolean = manager.areNotificationsEnabled() && manager.getNotificationChannel(CHANNEL_ID)?.importance != NotificationManager.IMPORTANCE_NONE - fun settingsIntent(): Intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - fun shouldRequestPermission(): Boolean = NotificationAccess.askOnLaunch( needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, granted = hasPermission(), diff --git a/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt b/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt index 612fee5..d2d12b8 100644 --- a/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt +++ b/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt @@ -1,35 +1,14 @@ package org.evsyukov.shareding.sync -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class NotificationAccessTest { - private fun access(needsPermission: Boolean = true, granted: Boolean = false, enabled: Boolean = true, - requestedBefore: Boolean = false, showRationale: Boolean = false) = - NotificationAccess.of(needsPermission, granted, enabled, requestedBefore, showRationale) - - @Test fun asksForPermissionUntilAndroidStopsShowingTheDialog() { - assertEquals(NotificationAccess.REQUEST_PERMISSION, access()) - assertEquals(NotificationAccess.REQUEST_PERMISSION, access(requestedBefore = true, showRationale = true)) - assertEquals(NotificationAccess.OPEN_SETTINGS, access(requestedBefore = true, showRationale = false)) - } - - @Test fun disabledNotificationsOpenSystemSettings() { - assertEquals(NotificationAccess.OPEN_SETTINGS, access(needsPermission = false, enabled = false)) - assertEquals(NotificationAccess.OPEN_SETTINGS, access(granted = true, enabled = false)) - } - @Test fun asksOnLaunchOnlyOnce() { assertTrue(NotificationAccess.askOnLaunch(needsPermission = true, granted = false, requestedBefore = false)) assertFalse(NotificationAccess.askOnLaunch(needsPermission = true, granted = false, requestedBefore = true)) assertFalse(NotificationAccess.askOnLaunch(needsPermission = true, granted = true, requestedBefore = false)) assertFalse(NotificationAccess.askOnLaunch(needsPermission = false, granted = true, requestedBefore = false)) } - - @Test fun grantedAndEnabledIsOn() { - assertEquals(NotificationAccess.ON, access(granted = true)) - assertEquals(NotificationAccess.ON, access(needsPermission = false)) - } } diff --git a/docs/specs/share-to-linkding.md b/docs/specs/share-to-linkding.md index 8033b7a..d40ed7d 100644 --- a/docs/specs/share-to-linkding.md +++ b/docs/specs/share-to-linkding.md @@ -36,7 +36,7 @@ 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. 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. +- 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. - 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. @@ -88,7 +88,7 @@ For R16–R17, Share titles serve as local labels. A persisted flag distinguishe - 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, 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. +- 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. - 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`.