Backup+restore, Firebase, formatting, custom intent action

This commit is contained in:
Philipp Heckel 2022-04-19 19:20:39 -04:00
parent 79c0e91e8d
commit 2625513216
11 changed files with 173 additions and 56 deletions

View file

@ -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<Notification> {
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<Action>?,
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<String,String>?, // used in "http" action
val body: String?, // used in "http" action
val intent: String?, // used in "broadcast" action
val extras: Map<String,String>?, // 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,

View file

@ -91,6 +91,7 @@ data class Action(
@ColumnInfo(name = "method") val method: String?, // used in "http" action
@ColumnInfo(name = "headers") val headers: Map<String,String>?, // 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<String,String>?, // 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

View file

@ -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)
}

View file

@ -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<String,String>?, // used in "http"
val body: String?, // used in "http"
val extras: Map<String,String>?, // 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<String,String>?, // used in "http" action
val body: String?, // used in "http" action
val intent: String?, // used in "broadcast" action
val extras: Map<String,String>?, // used in "broadcast" action
)
const val MESSAGE_ENCODING_BASE64 = "base64"

View file

@ -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<Action>? {
val listType: Type = object : TypeToken<List<MessageAction>?>() {}.type
val messageActions: List<MessageAction>? = 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)
}

View file

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

View file

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

View file

@ -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<Notification>() {

View file

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

View file

@ -4,7 +4,7 @@
The translatable="false" attribute is just an additional safety. -->
<!-- Main app constants -->
<string name="app_name" translatable="false">Ntfy</string>
<string name="app_name" translatable="false">ntfy</string>
<string name="app_base_url" translatable="false">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
<!-- Main activity -->

View file

@ -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)
}
}