diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6ca9d3c00..91cf094ab 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,6 +41,9 @@
+
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 251a4ba0e..6c5c9aca5 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -227,6 +227,9 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
showHideBatteryBanner(subscriptions)
showHideWebSocketBanner(subscriptions)
showHideWebSocketReconnectBanner()
+
+ // Update share targets in the system share sheet
+ ShareTargetHelper.update(this@MainActivity, subscriptions)
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
index d9bcc31b9..c6735a97c 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
@@ -32,6 +32,7 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
val distributor = Distributor(context)
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
}
+ ShareTargetHelper.remove(context, subscription)
repository.removeSubscription(subscription)
if (subscription.icon != null) {
val resolver = context.applicationContext.contentResolver
diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
index a7a627f37..fec1688ab 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
@@ -1,5 +1,6 @@
package io.heckel.ntfy.ui
+import android.app.TaskStackBuilder
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -10,6 +11,7 @@ import android.view.*
import android.widget.*
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -23,6 +25,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.core.view.size
import androidx.core.view.get
+import io.heckel.ntfy.db.Subscription
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_BASE_URL
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_DISPLAY_NAME
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_ID
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_INSTANT
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_MUTED_UNTIL
+import io.heckel.ntfy.ui.MainActivity.Companion.EXTRA_SUBSCRIPTION_TOPIC
+import io.heckel.ntfy.ui.ShareTargetHelper.EXTRA_TOPIC_URL
class ShareActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository }
@@ -60,6 +70,17 @@ class ShareActivity : AppCompatActivity() {
Log.init(this) // Init logs in all entry points
Log.d(TAG, "Create $this with intent $intent")
+ // If launched from a sharing shortcut, extract the pre-selected topic
+ val shortcutTopicUrl = intent?.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
+ ?.let { ShareTargetHelper.topicUrlFromShortcutId(it) }
+ ?: intent?.getStringExtra(EXTRA_TOPIC_URL)
+
+ // If this activity was opened from the launcher shortcut, reroute to DetailActivity instead.
+ if (intent.action == Intent.ACTION_VIEW) {
+ openDetailActivityAndFinish(shortcutTopicUrl)
+ return
+ }
+
// Action bar
val toolbarLayout = findViewById(R.id.app_bar_drawer)
val dynamicColors = repository.getDynamicColorsEnabled()
@@ -183,10 +204,31 @@ class ShareActivity : AppCompatActivity() {
baseUrls.count() == 1
}
baseUrlLayout.visibility = if (useAnotherServerCheckbox.isChecked) View.VISIBLE else View.GONE
+
+ handleIncomingIntent()
+
+ // Override defaults if launched from a sharing shortcut
+ shortcutTopicUrl?.let { url ->
+ try {
+ val (baseUrl, topic) = splitTopicUrl(url)
+ val defaultUrl = defaultBaseUrl ?: appBaseUrl
+ topicText.text = topic
+ useAnotherServerCheckbox.isChecked = baseUrl != defaultUrl
+ if (baseUrl != defaultUrl) baseUrlText.setText(baseUrl)
+ baseUrlLayout.visibility = if (useAnotherServerCheckbox.isChecked) View.VISIBLE else View.GONE
+ validateInput()
+ if ((contentText.text.isNotEmpty() || fileUri != null) && topicText.text.isNotEmpty()) {
+ onShareClick()
+ }
+ } catch (_: Exception) {
+ // Ignore malformed shortcut IDs
+ }
+ }
}
}
+ }
- // Incoming intent
+ private fun handleIncomingIntent() {
val intent = intent ?: return
val type = intent.type ?: return
if (intent.action != Intent.ACTION_SEND) return
@@ -199,6 +241,46 @@ class ShareActivity : AppCompatActivity() {
}
}
+ private fun openDetailActivityAndFinish(shortcutTopicUrl: String?) {
+ if (shortcutTopicUrl == null) {
+ finish()
+ return
+ }
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val (baseUrl, topic) = splitTopicUrl(shortcutTopicUrl)
+ val subscription = repository.getSubscription(baseUrl, topic)
+ startDetailActivityAndFinish(subscription)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to get subscription to start DetailActivity", e)
+ withContext(Dispatchers.Main) { finish() }
+ }
+ }
+ }
+
+ private suspend fun startDetailActivityAndFinish(subscription: Subscription?) =
+ withContext(Dispatchers.Main) {
+ if (subscription != null) {
+ TaskStackBuilder.create(this@ShareActivity)
+ .addNextIntentWithParentStack(createDetailActivityIntent(subscription))
+ .startActivities()
+ }
+ finish()
+ }
+
+ private fun createDetailActivityIntent(subscription: Subscription): Intent =
+ Intent(this, DetailActivity::class.java).apply {
+ putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id)
+ putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
+ putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
+ putExtra(
+ EXTRA_SUBSCRIPTION_DISPLAY_NAME,
+ subscription.displayName ?: subscription.topic
+ )
+ putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
+ putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
+ }
+
private fun handleSendText(intent: Intent) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "(no text)"
Log.d(TAG, "Shared content is text: $text")
diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareTargetHelper.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareTargetHelper.kt
new file mode 100644
index 000000000..2145bb658
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/ShareTargetHelper.kt
@@ -0,0 +1,78 @@
+package io.heckel.ntfy.ui
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import io.heckel.ntfy.R
+import io.heckel.ntfy.db.Subscription
+import io.heckel.ntfy.util.topicUrl
+
+object ShareTargetHelper {
+
+ const val EXTRA_TOPIC_URL = "EXTRA_TOPIC_URL"
+
+ private const val CATEGORY_SHARE_TARGET = "io.heckel.ntfy.SHARE_TARGET"
+ private const val SHORTCUT_ID_PREFIX = "share_"
+
+ private const val TAG = "ShareTargetHelper"
+
+ /**
+ * Publishes one dynamic sharing shortcut per subscription so that topics appear
+ * as direct-share targets in the system share sheet (Android 10+).
+ */
+ fun update(context: Context, subscriptions: List) {
+ val max = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
+ val shortcuts = subscriptions
+ .sortedByDescending { it.lastActive }
+ .take(max)
+ .map { sub -> buildShortcut(context, sub) }
+ try {
+ val success = ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
+ android.util.Log.i(
+ TAG,
+ "setDynamicShortcuts returned $success"
+ )
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "setDynamicShortcuts returned failed", e)
+ }
+ }
+
+ /**
+ * Removes the dynamic sharing shortcut for a single subscription.
+ */
+ fun remove(context: Context, subscription: Subscription) {
+ val id = SHORTCUT_ID_PREFIX + topicUrl(subscription.baseUrl, subscription.topic)
+ ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(id))
+ }
+
+ /**
+ * Returns the topicUrl encoded in a shortcut ID, or null if the ID doesn't belong to us.
+ */
+ fun topicUrlFromShortcutId(shortcutId: String): String? {
+ if (!shortcutId.startsWith(SHORTCUT_ID_PREFIX)) return null
+ return shortcutId.removePrefix(SHORTCUT_ID_PREFIX)
+ }
+
+ private fun buildShortcut(context: Context, sub: Subscription): ShortcutInfoCompat {
+ val label = sub.displayName ?: sub.topic
+ val url = topicUrl(sub.baseUrl, sub.topic)
+ val icon = sub.icon?.let { IconCompat.createWithAdaptiveBitmapContentUri(it) }
+ ?: IconCompat.createWithResource(context, R.mipmap.ic_launcher)
+ val intent = Intent(context, ShareActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ // When ShareActivity is started using the shortcut from the launcher, its intent
+ // doesn't include the SHORTCUT_ID extra. That's why we pass the topic URL here.
+ putExtra(EXTRA_TOPIC_URL, url)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ return ShortcutInfoCompat.Builder(context, SHORTCUT_ID_PREFIX + url)
+ .setShortLabel(label)
+ .setIcon(icon)
+ .setCategories(setOf(CATEGORY_SHARE_TARGET))
+ .setIntent(intent)
+ .setLongLived(true)
+ .build()
+ }
+}
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 000000000..c52c0cbc0
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+