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 @@ + + + + + + + + + + +