diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 5614536..5c5ea2c 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.backup import android.content.Context import android.net.Uri +import androidx.room.ColumnInfo import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.stream.JsonReader @@ -109,6 +110,25 @@ class Backuper(val context: Context) { } notifications.forEach { n -> try { + val actions = if (n.actions != null) { + n.actions.map { a -> + io.heckel.ntfy.db.Action( + id = a.id, + action = a.action, + label = a.label, + url = a.url, + method = a.method, + headers = a.headers, + body = a.body, + intent = a.intent, + extras = a.extras, + progress = a.progress, + error = a.error + ) + } + } else { + null + } val attachment = if (n.attachment != null) { io.heckel.ntfy.db.Attachment( name = n.attachment.name, @@ -133,7 +153,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, - actions = null, // FIXME + actions = actions, attachment = attachment, deleted = n.deleted )) @@ -202,6 +222,25 @@ class Backuper(val context: Context) { private suspend fun createNotificationList(): List { return repository.getNotifications().map { n -> + val actions = if (n.actions != null) { + n.actions.map { a -> + Action( + id = a.id, + action = a.action, + label = a.label, + url = a.url, + method = a.method, + headers = a.headers, + body = a.body, + intent = a.intent, + extras = a.extras, + progress = a.progress, + error = a.error + ) + } + } else { + null + } val attachment = if (n.attachment != null) { Attachment( name = n.attachment.name, @@ -225,6 +264,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, + actions = actions, attachment = attachment, deleted = n.deleted ) @@ -291,10 +331,25 @@ data class Notification( val priority: Int, // 1=min, 3=default, 5=max val tags: String, val click: String, // URL/intent to open on notification click + val actions: List?, val attachment: Attachment?, val deleted: Boolean ) +data class Action( + val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager + val action: String, // "view", "http" or "broadcast" + val label: String, + val url: String?, // used in "view" and "http" actions + val method: String?, // used in "http" action + val headers: Map?, // used in "http" action + val body: String?, // used in "http" action + val intent: String?, // used in "broadcast" action + val extras: Map?, // used in "broadcast" action + val progress: Int?, // used to indicate progress in popup + val error: String? // used to indicate errors in popup +) + data class Attachment( val name: String, // Filename val type: String?, // MIME type @@ -305,7 +360,6 @@ data class Attachment( val progress: Int, // Progress during download, -1 if not downloaded ) - data class User( val baseUrl: String, val username: String, diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 92d9249..d5e6a87 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -91,6 +91,7 @@ data class Action( @ColumnInfo(name = "method") val method: String?, // used in "http" action @ColumnInfo(name = "headers") val headers: Map?, // used in "http" action @ColumnInfo(name = "body") val body: String?, // used in "http" action + @ColumnInfo(name = "intent") val intent: String?, // used in "broadcast" action @ColumnInfo(name = "extras") val extras: Map?, // used in "broadcast" action @ColumnInfo(name = "progress") val progress: Int?, // used to indicate progress in popup @ColumnInfo(name = "error") val error: String?, // used to indicate errors in popup diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index 7cf5a55..e5f46fd 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -34,17 +34,17 @@ class BroadcastService(private val ctx: Context) { intent.putExtra("muted", muted) intent.putExtra("muted_str", muted.toString()) - Log.d(TAG, "Sending message intent broadcast: $intent") + Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}") ctx.sendBroadcast(intent) } fun sendUserAction(action: Action) { val intent = Intent() - intent.action = USER_ACTION_ACTION + intent.action = action.intent ?: USER_ACTION_ACTION action.extras?.forEach { (key, value) -> intent.putExtra(key, value) } - Log.d(TAG, "Sending user action intent broadcast: $intent") + Log.d(TAG, "Sending user action intent broadcast: ${intent.action} with extras ${intent.extras}") ctx.sendBroadcast(intent) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 1d86c17..e2fcd76 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -35,11 +35,12 @@ data class MessageAction( val id: String, val action: String, val label: String, // "view", "broadcast" or "http" - val url: String?, // used in "view" and "http" - val method: String?, // used in "http", default is POST (!) - val headers: Map?, // used in "http" - val body: String?, // used in "http" - val extras: Map?, // used in "broadcast" + val url: String?, // used in "view" and "http" actions + val method: String?, // used in "http" action, default is POST (!) + val headers: Map?, // used in "http" action + val body: String?, // used in "http" action + val intent: String?, // used in "broadcast" action + val extras: Map?, // used in "broadcast" action ) const val MESSAGE_ENCODING_BASE64 = "base64" diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 4d00781..b855d24 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -1,13 +1,13 @@ package io.heckel.ntfy.msg -import android.util.Base64 import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.joinTags -import io.heckel.ntfy.util.randomString import io.heckel.ntfy.util.toPriority +import java.lang.reflect.Type class NotificationParser { private val gson = Gson() @@ -33,7 +33,7 @@ class NotificationParser { } else null val actions = if (message.actions != null) { message.actions.map { a -> - Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.extras, null, null) + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null) } } else null val notification = Notification( @@ -54,5 +54,17 @@ class NotificationParser { return NotificationWithTopic(message.topic, notification) } + /** + * Parse JSON array to Action list. The indirection via MessageAction is probably + * not necessary, but for "good form". + */ + fun parseActions(s: String?): List? { + val listType: Type = object : TypeToken?>() {}.type + val messageActions: List? = gson.fromJson(s, listType) + return messageActions?.map { a -> + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null) + } + } + data class NotificationWithTopic(val topic: String, val notification: Notification) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index db3b70d..4cbb18b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -129,18 +129,6 @@ class NotificationService(val context: Context) { return context.getString(R.string.notification_popup_file, message, attachmentInfos) } - private fun maybeAppendActionErrors(message: String, notification: Notification): String { - val actionErrors = notification.actions - .orEmpty() - .mapNotNull { action -> action.error } - .joinToString("\n") - if (actionErrors.isEmpty()) { - return message - } else { - return "${message}\n\n${actionErrors}" - } - } - private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { if (notification.click == "") { builder.setContentIntent(detailActivityIntent(subscription)) @@ -218,6 +206,10 @@ class NotificationService(val context: Context) { } private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) { + // Note that this function is (almost) duplicated in DetailAdapter, since we need to be able + // to open a link from the detail activity as well. We can't do this in the UserActionWorker, + // because the behavior is kind of weird in Android. + try { val url = action.url ?: return val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { @@ -237,12 +229,7 @@ class NotificationService(val context: Context) { putExtra(BROADCAST_EXTRA_ACTION_ID, action.id) } val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val label = when (action.progress) { - ACTION_PROGRESS_ONGOING -> action.label + " …" - ACTION_PROGRESS_SUCCESS -> action.label + " ✔️" - ACTION_PROGRESS_FAILED -> action.label + " ❌️" - else -> action.label - } + val label = formatActionLabel(action) builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build()) } @@ -322,20 +309,20 @@ class NotificationService(val context: Context) { } companion object { - val ACTION_VIEW = "view" - val ACTION_HTTP = "http" - val ACTION_BROADCAST = "broadcast" + const val ACTION_VIEW = "view" + const val ACTION_HTTP = "http" + const val ACTION_BROADCAST = "broadcast" + + const val BROADCAST_EXTRA_TYPE = "type" + const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" + const val BROADCAST_EXTRA_ACTION_ID = "action" + + const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" + const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" + const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN" private const val TAG = "NtfyNotifService" - private const val BROADCAST_EXTRA_TYPE = "type" - private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" - private const val BROADCAST_EXTRA_ACTION_ID = "action" - - private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" - private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" - private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN" - private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_DEFAULT = "ntfy" diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt index b6fba90..91f1aa6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -1,6 +1,8 @@ package io.heckel.ntfy.msg import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.work.Worker import androidx.work.WorkerParameters import io.heckel.ntfy.R @@ -8,6 +10,7 @@ import io.heckel.ntfy.app.Application import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.Log import okhttp3.OkHttpClient import okhttp3.Request @@ -43,8 +46,11 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : Log.d(TAG, "Executing action $action for notification $notification") try { when (action.action) { - ACTION_HTTP -> performHttpAction(action) + // ACTION_VIEW is not handled here. It has to be handled in the foreground to avoid + // weird Android behavior. + ACTION_BROADCAST -> performBroadcastAction(action) + ACTION_HTTP -> performHttpAction(action) } } catch (e: Exception) { Log.w(TAG, "Error executing action: ${e.message}", e) @@ -56,6 +62,11 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : return Result.success() } + private fun performBroadcastAction(action: Action) { + broadcaster.sendUserAction(action) + save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) + } + private fun performHttpAction(action: Action) { save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null)) @@ -81,11 +92,6 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : } } - private fun performBroadcastAction(action: Action) { - broadcaster.sendUserAction(action) - save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) - } - private fun save(newAction: Action) { Log.d(TAG, "Updating action: $newAction") val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 64a361d..bb64ff1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -25,6 +25,8 @@ import io.heckel.ntfy.R import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadWorker +import io.heckel.ntfy.msg.NotificationService +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -81,7 +83,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo val unmatchedTags = unmatchedTags(splitTags(notification.tags)) dateView.text = formatDateShort(notification.timestamp) - messageView.text = formatMessage(notification) + messageView.text = maybeAppendActionErrors(formatMessage(notification), notification) newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE itemView.setOnClickListener { onClick(notification) } itemView.setOnLongClickListener { onLongClick(notification); true } @@ -179,6 +181,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo val attachment = notification.attachment // May be null val hasAttachment = attachment != null val hasClickLink = notification.click != "" + val hasUserActions = notification.actions?.isNotEmpty() ?: false val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) val openItem = popup.menu.findItem(R.id.detail_item_menu_open) @@ -199,6 +202,12 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo if (hasClickLink) { copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) } } + if (notification.actions != null && notification.actions.isNotEmpty()) { + notification.actions.forEach { action -> + val actionItem = popup.menu.add(formatActionLabel(action)) + actionItem.setOnMenuItemClickListener { runAction(context, notification, action) } + } + } openItem.isVisible = hasAttachment && exists downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress deleteItem.isVisible = hasAttachment && exists @@ -208,7 +217,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo copyContentsItem.isVisible = notification.click != "" val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible && !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible - && !copyContentsItem.isVisible + && !copyContentsItem.isVisible && !hasUserActions if (noOptions) { return null } @@ -401,6 +410,31 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo copyToClipboard(context, notification) return true } + + private fun runAction(context: Context, notification: Notification, action: Action): Boolean { + when (action.action) { + ACTION_VIEW -> runViewAction(context, action) + else -> runOtherUserAction(context, notification, action) + } + return true + } + + private fun runViewAction(context: Context, action: Action) { + val url = action.url ?: return + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } + + private fun runOtherUserAction(context: Context, notification: Notification, action: Action) { + val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply { + putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION) + putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) + putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id) + } + context.sendBroadcast(intent) + } } object TopicDiffCallback : DiffUtil.ItemCallback() { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index f915215..cea4c86 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -23,9 +23,7 @@ import android.widget.ImageView import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate import io.heckel.ntfy.R -import io.heckel.ntfy.db.Notification -import io.heckel.ntfy.db.Repository -import io.heckel.ntfy.db.Subscription +import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -185,6 +183,27 @@ fun formatTitle(notification: Notification): String { } } +fun formatActionLabel(action: Action): String { + return when (action.progress) { + ACTION_PROGRESS_ONGOING -> action.label + " …" + ACTION_PROGRESS_SUCCESS -> action.label + " ✔️" + ACTION_PROGRESS_FAILED -> action.label + " ❌️" + else -> action.label + } +} + +fun maybeAppendActionErrors(message: String, notification: Notification): String { + val actionErrors = notification.actions + .orEmpty() + .mapNotNull { action -> action.error } + .joinToString("\n") + if (actionErrors.isEmpty()) { + return message + } else { + return "${message}\n\n${actionErrors}" + } +} + // Checks in the most horrible way if a content URI exists; I couldn't find a better way fun fileExists(context: Context, contentUri: String?): Boolean { return try { diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 9e6d883..4d44d2a 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -4,7 +4,7 @@ The translatable="false" attribute is just an additional safety. --> - Ntfy + ntfy https://ntfy.sh diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index a3db0db..ec1e130 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -13,6 +13,7 @@ import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 import io.heckel.ntfy.msg.NotificationDispatcher +import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.topicShortUrl @@ -27,6 +28,7 @@ class FirebaseService : FirebaseMessagingService() { private val dispatcher by lazy { NotificationDispatcher(this, repository) } private val job = SupervisorJob() private val messenger = FirebaseMessenger() + private val parser = NotificationParser() override fun onMessageReceived(remoteMessage: RemoteMessage) { // Init log (this is done in all entrypoints) @@ -88,6 +90,7 @@ class FirebaseService : FirebaseMessagingService() { val priority = data["priority"]?.toIntOrNull() val tags = data["tags"] val click = data["click"] + val actions = data["actions"] // JSON array as string, sigh ... val encoding = data["encoding"] val attachmentName = data["attachment_name"] ?: "attachment.bin" val attachmentType = data["attachment_type"] @@ -131,13 +134,13 @@ class FirebaseService : FirebaseMessagingService() { priority = toPriority(priority), tags = tags ?: "", click = click ?: "", - actions = null, // FIXME + actions = parser.parseActions(actions), attachment = attachment, notificationId = Random.nextInt(), deleted = false ) if (repository.addNotification(notification)) { - Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") + Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") dispatcher.dispatch(subscription, notification) } }