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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ android {

defaultConfig {
applicationId = "org.evsyukov.shareding"
minSdk = 28
minSdk = 29
targetSdk = 36
versionCode = 22
versionName = "0.2.4-rc.1"
Expand Down
40 changes: 38 additions & 2 deletions app/src/androidTest/java/org/evsyukov/shareding/QueueUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -164,6 +166,37 @@ 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 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)
Expand Down Expand Up @@ -244,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)
Expand All @@ -258,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() {
Expand Down Expand Up @@ -417,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
})
}
Expand Down
72 changes: 72 additions & 0 deletions app/src/androidTest/java/org/evsyukov/shareding/ShareIntentTest.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,92 @@
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.core.content.pm.ShortcutManagerCompat
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 appStartPublishesDirectShareTarget() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
var categories = emptyList<Set<String>>()
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
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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -115,6 +127,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<SyncWorker>(context).build().doWork()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading