diff --git a/README.md b/README.md index 45f94db..c550ee1 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**. 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 after the first sync error; 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, 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. 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. diff --git a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt index b6b5647..53c0ff4 100644 --- a/app/src/main/java/org/evsyukov/shareding/MainActivity.kt +++ b/app/src/main/java/org/evsyukov/shareding/MainActivity.kt @@ -196,9 +196,9 @@ private fun ShareDingScreen(container: AppContainer) { val notificationPermission = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission()) { } - LaunchedEffect(settings.lastError) { - // Ask once, after sync has failed, so the request has an obvious reason. - if (settings.lastError.isNotBlank() && container.notifier.shouldRequestPermission()) { + LaunchedEffect(Unit) { + // Ask once, on first launch: a sync error in the background cannot show the dialog itself. + if (container.notifier.shouldRequestPermission()) { container.notifier.markPermissionRequested() notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } 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 788f7f4..0cbe6e3 100644 --- a/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt +++ b/app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt @@ -51,6 +51,10 @@ enum class NotificationAccess { ON, REQUEST_PERMISSION, 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 } } @@ -104,9 +108,11 @@ class SyncNotifier(private val context: Context, private val now: () -> Long = S fun settingsIntent(): Intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - fun shouldRequestPermission(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPermission() && - !prefs.getBoolean(KEY_PERMISSION_REQUESTED, false) + fun shouldRequestPermission(): Boolean = NotificationAccess.askOnLaunch( + needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, + granted = hasPermission(), + requestedBefore = prefs.getBoolean(KEY_PERMISSION_REQUESTED, false), + ) fun markPermissionRequested() { prefs.edit().putBoolean(KEY_PERMISSION_REQUESTED, true).apply() 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 676d027..612fee5 100644 --- a/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt +++ b/app/src/test/java/org/evsyukov/shareding/sync/NotificationAccessTest.kt @@ -1,6 +1,8 @@ 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 { @@ -19,6 +21,13 @@ class NotificationAccessTest { 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 b28456a..cf83030 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. On Android 13+, request the notification permission once, after a sync error is recorded. 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. 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.