Skip to content
Open
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
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>

<!-- Detail activity -->
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 83 additions & 1 deletion app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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<View>(R.id.app_bar_drawer)
val dynamicColors = repository.getDynamicColorsEnabled()
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
78 changes: 78 additions & 0 deletions app/src/main/java/io/heckel/ntfy/ui/ShareTargetHelper.kt
Original file line number Diff line number Diff line change
@@ -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<Subscription>) {
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()
}
}
11 changes: 11 additions & 0 deletions app/src/main/res/xml/shortcuts.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="io.heckel.ntfy.ui.ShareActivity">
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*" />
<category android:name="io.heckel.ntfy.SHARE_TARGET" />
</share-target>
</shortcuts>