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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
47 changes: 2 additions & 45 deletions app/src/main/java/org/evsyukov/shareding/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -438,42 +434,6 @@ private fun QueueScreen(bookmarks: List<Bookmark>, 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
Expand Down Expand Up @@ -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<String>, tagLoadError: Boolean,
onSave: suspend (String, String?, String, Boolean, Boolean) -> Unit,
onTest: suspend (String, String) -> Unit,
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 4 additions & 36 deletions app/src/main/java/org/evsyukov/shareding/sync/SyncNotifier.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
package org.evsyukov.shareding.sync

import android.Manifest
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
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
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading