Actions WIP

This commit is contained in:
Philipp Heckel 2022-04-19 09:15:06 -04:00
parent 686616d4d2
commit 79c0e91e8d
11 changed files with 153 additions and 74 deletions

View file

@ -73,20 +73,33 @@ data class Attachment(
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded @ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
) { ) {
constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
this(name, type, size, expires, url, null, PROGRESS_NONE) this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE)
} }
const val ATTACHMENT_PROGRESS_NONE = -1
const val ATTACHMENT_PROGRESS_INDETERMINATE = -2
const val ATTACHMENT_PROGRESS_FAILED = -3
const val ATTACHMENT_PROGRESS_DELETED = -4
const val ATTACHMENT_PROGRESS_DONE = 100
@Entity @Entity
data class Action( data class Action(
@ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager @ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
@ColumnInfo(name = "action") val action: String, @ColumnInfo(name = "action") val action: String, // "view", "http" or "broadcast"
@ColumnInfo(name = "label") val label: String, @ColumnInfo(name = "label") val label: String,
@ColumnInfo(name = "url") val url: String?, // used in "view" and "http" @ColumnInfo(name = "url") val url: String?, // used in "view" and "http" actions
@ColumnInfo(name = "method") val method: String?, // used in "http" @ColumnInfo(name = "method") val method: String?, // used in "http" action
@ColumnInfo(name = "headers") val headers: Map<String,String>?, // used in "http" @ColumnInfo(name = "headers") val headers: Map<String,String>?, // used in "http" action
@ColumnInfo(name = "body") val body: String?, // used in "http" @ColumnInfo(name = "body") val body: String?, // used in "http" 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
) )
const val ACTION_PROGRESS_ONGOING = 1
const val ACTION_PROGRESS_SUCCESS = 2
const val ACTION_PROGRESS_FAILED = 3
class Converters { class Converters {
private val gson = Gson() private val gson = Gson()
@ -102,12 +115,6 @@ class Converters {
} }
} }
const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_FAILED = -3
const val PROGRESS_DELETED = -4
const val PROGRESS_DONE = 100
@Entity @Entity
data class User( data class User(
@PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String, @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String,

View file

@ -2,8 +2,8 @@ package io.heckel.ntfy.msg
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Base64
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
* in order to facilitate tasks app integrations. * in order to facilitate tasks app integrations.
*/ */
class BroadcastService(private val ctx: Context) { class BroadcastService(private val ctx: Context) {
fun send(subscription: Subscription, notification: Notification, muted: Boolean) { fun sendMessage(subscription: Subscription, notification: Notification, muted: Boolean) {
val intent = Intent() val intent = Intent()
intent.action = MESSAGE_RECEIVED_ACTION intent.action = MESSAGE_RECEIVED_ACTION
intent.putExtra("id", notification.id) intent.putExtra("id", notification.id)
@ -34,7 +34,17 @@ class BroadcastService(private val ctx: Context) {
intent.putExtra("muted", muted) intent.putExtra("muted", muted)
intent.putExtra("muted_str", muted.toString()) intent.putExtra("muted_str", muted.toString())
Log.d(TAG, "Sending intent broadcast: $intent") Log.d(TAG, "Sending message intent broadcast: $intent")
ctx.sendBroadcast(intent)
}
fun sendUserAction(action: Action) {
val intent = Intent()
intent.action = USER_ACTION_ACTION
action.extras?.forEach { (key, value) ->
intent.putExtra(key, value)
}
Log.d(TAG, "Sending user action intent broadcast: $intent")
ctx.sendBroadcast(intent) ctx.sendBroadcast(intent)
} }
@ -109,5 +119,6 @@ class BroadcastService(private val ctx: Context) {
// These constants cannot be changed without breaking the contract; also see manifest // These constants cannot be changed without breaking the contract; also see manifest
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED" private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
} }
} }

View file

@ -91,13 +91,13 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
while (bytes >= 0) { while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) { if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
if (isStopped) { // Canceled by user if (isStopped) { // Canceled by user
save(attachment.copy(progress = PROGRESS_NONE)) save(attachment.copy(progress = ATTACHMENT_PROGRESS_NONE))
return // File will be deleted in onStopped() return // File will be deleted in onStopped()
} }
val progress = if (attachment.size != null && attachment.size!! > 0) { val progress = if (attachment.size != null && attachment.size!! > 0) {
(bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt() (bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
} else { } else {
PROGRESS_INDETERMINATE ATTACHMENT_PROGRESS_INDETERMINATE
} }
save(attachment.copy(progress = progress)) save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis() lastProgress = System.currentTimeMillis()
@ -114,7 +114,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
save(attachment.copy( save(attachment.copy(
size = bytesCopied, size = bytesCopied,
contentUri = uri.toString(), contentUri = uri.toString(),
progress = PROGRESS_DONE progress = ATTACHMENT_PROGRESS_DONE
)) ))
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -155,7 +155,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
private fun failed(e: Exception) { private fun failed(e: Exception) {
Log.w(TAG, "Attachment download failed", e) Log.w(TAG, "Attachment download failed", e)
save(attachment.copy(progress = PROGRESS_FAILED)) save(attachment.copy(progress = ATTACHMENT_PROGRESS_FAILED))
maybeDeleteFile() maybeDeleteFile()
} }

View file

@ -34,11 +34,12 @@ data class MessageAttachment(
data class MessageAction( data class MessageAction(
val id: String, val id: String,
val action: String, val action: String,
val label: String, val label: String, // "view", "broadcast" or "http"
val url: String?, // used in "view" and "http" val url: String?, // used in "view" and "http"
val method: String?, // used in "http" val method: String?, // used in "http", default is POST (!)
val headers: Map<String,String>?, // used in "http" val headers: Map<String,String>?, // used in "http"
val body: String?, // used in "http" val body: String?, // used in "http"
val extras: Map<String,String>?, // used in "broadcast"
) )
const val MESSAGE_ENCODING_BASE64 = "base64" const val MESSAGE_ENCODING_BASE64 = "base64"

View file

@ -1,7 +1,6 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.content.Context import android.content.Context
import android.util.Base64
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
@ -35,7 +34,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
notifier.display(subscription, notification) notifier.display(subscription, notification)
} }
if (broadcast) { if (broadcast) {
broadcaster.send(subscription, notification, muted) broadcaster.sendMessage(subscription, notification, muted)
} }
if (distribute) { if (distribute) {
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken -> safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->

View file

@ -33,7 +33,7 @@ class NotificationParser {
} else null } else null
val actions = if (message.actions != null) { val actions = if (message.actions != null) {
message.actions.map { a -> message.actions.map { a ->
Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body) Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.extras, null, null)
} }
} else null } else null
val notification = Notification( val notification = Notification(

View file

@ -66,7 +66,7 @@ class NotificationService(val context: Context) {
maybeAddBrowseAction(builder, notification) maybeAddBrowseAction(builder, notification)
maybeAddDownloadAction(builder, notification) maybeAddDownloadAction(builder, notification)
maybeAddCancelAction(builder, notification) maybeAddCancelAction(builder, notification)
maybeAddCustomActions(builder, notification) maybeAddUserActions(builder, notification)
maybeCreateNotificationChannel(notification.priority) maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build()) notificationManager.notify(notification.notificationId, builder.build())
@ -90,43 +90,55 @@ class NotificationService(val context: Context) {
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri)) val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream) val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder builder
.setContentText(formatMessage(notification)) .setContentText(maybeAppendActionErrors(formatMessage(notification), notification))
.setLargeIcon(bitmap) .setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle() .setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap) .bigPicture(bitmap)
.bigLargeIcon(null)) .bigLargeIcon(null))
} catch (_: Exception) { } catch (_: Exception) {
val message = formatMessageMaybeWithAttachmentInfo(notification) val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder builder
.setContentText(message) .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message)) .setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }
} else { } else {
val message = formatMessageMaybeWithAttachmentInfo(notification) val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder builder
.setContentText(message) .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message)) .setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }
} }
private fun formatMessageMaybeWithAttachmentInfo(notification: Notification): String { private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String {
val message = formatMessage(notification) val message = formatMessage(notification)
val attachment = notification.attachment ?: return message val attachment = notification.attachment ?: return message
val infos = if (attachment.size != null) { val attachmentInfos = if (attachment.size != null) {
"${attachment.name}, ${formatBytes(attachment.size)}" "${attachment.name}, ${formatBytes(attachment.size)}"
} else { } else {
attachment.name attachment.name
} }
if (attachment.progress in 0..99) { if (attachment.progress in 0..99) {
return context.getString(R.string.notification_popup_file_downloading, infos, attachment.progress, message) return context.getString(R.string.notification_popup_file_downloading, attachmentInfos, attachment.progress, message)
} }
if (attachment.progress == PROGRESS_DONE) { if (attachment.progress == ATTACHMENT_PROGRESS_DONE) {
return context.getString(R.string.notification_popup_file_download_successful, message, infos) return context.getString(R.string.notification_popup_file_download_successful, message, attachmentInfos)
} }
if (attachment.progress == PROGRESS_FAILED) { if (attachment.progress == ATTACHMENT_PROGRESS_FAILED) {
return context.getString(R.string.notification_popup_file_download_failed, message, infos) return context.getString(R.string.notification_popup_file_download_failed, message, attachmentInfos)
}
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}"
} }
return context.getString(R.string.notification_popup_file, message, infos)
} }
private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) {
@ -135,7 +147,7 @@ class NotificationService(val context: Context) {
} else { } else {
try { try {
val uri = Uri.parse(notification.click) val uri = Uri.parse(notification.click)
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE) val viewIntent = PendingIntent.getActivity(context, Random().nextInt(), Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE)
builder.setContentIntent(viewIntent) builder.setContentIntent(viewIntent)
} catch (e: Exception) { } catch (e: Exception) {
builder.setContentIntent(detailActivityIntent(subscription)) builder.setContentIntent(detailActivityIntent(subscription))
@ -159,7 +171,7 @@ class NotificationService(val context: Context) {
setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build())
} }
} }
@ -169,18 +181,18 @@ class NotificationService(val context: Context) {
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply { val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
} }
} }
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { if (notification.attachment?.contentUri == null && listOf(ATTACHMENT_PROGRESS_NONE, ATTACHMENT_PROGRESS_FAILED).contains(notification.attachment?.progress)) {
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply { val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START) putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
} }
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build())
} }
} }
@ -191,47 +203,51 @@ class NotificationService(val context: Context) {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL) putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
} }
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build())
} }
} }
private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddUserActions(builder: NotificationCompat.Builder, notification: Notification) {
notification.actions?.forEach { action -> notification.actions?.forEach { action ->
when (action.action.lowercase(Locale.getDefault())) { when (action.action.lowercase(Locale.getDefault())) {
ACTION_VIEW -> maybeAddViewUserAction(builder, action) ACTION_VIEW -> maybeAddViewUserAction(builder, action)
ACTION_HTTP -> maybeAddHttpUserAction(builder, notification, action) ACTION_HTTP, ACTION_BROADCAST -> maybeAddHttpOrBroadcastUserAction(builder, notification, action)
} }
} }
} }
private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) { private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) {
Log.d(TAG, "Adding user action $action")
try { try {
val url = action.url ?: return val url = action.url ?: return
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Unable to add open user action", e) Log.w(TAG, "Unable to add open user action", e)
} }
} }
private fun maybeAddHttpUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { private fun maybeAddHttpOrBroadcastUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply { val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_USER_ACTION) putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_USER_ACTION)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
putExtra(BROADCAST_EXTRA_ACTION_ID, action.id) putExtra(BROADCAST_EXTRA_ACTION_ID, action.id)
} }
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) val label = when (action.progress) {
ACTION_PROGRESS_ONGOING -> action.label + ""
ACTION_PROGRESS_SUCCESS -> action.label + " ✔️"
ACTION_PROGRESS_FAILED -> action.label + " ❌️"
else -> action.label
}
builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build())
} }
class UserActionBroadcastReceiver : BroadcastReceiver() { class UserActionBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Notification user action intent received: $intent")
val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
when (type) { when (type) {
@ -306,19 +322,19 @@ class NotificationService(val context: Context) {
} }
companion object { companion object {
val ACTION_VIEW = "view"
val ACTION_HTTP = "http"
val ACTION_BROADCAST = "broadcast"
private const val TAG = "NtfyNotifService" private const val TAG = "NtfyNotifService"
private const val BROADCAST_EXTRA_TYPE = "type" private const val BROADCAST_EXTRA_TYPE = "type"
private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId"
private const val BROADCAST_EXTRA_ACTION_ID = "action" private const val BROADCAST_EXTRA_ACTION_ID = "action"
private const val BROADCAST_EXTRA_ACTION_JSON = "actionJson"
private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" 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_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION" private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN"
private const val ACTION_VIEW = "view"
private const val ACTION_HTTP = "http"
private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -3,55 +3,99 @@ package io.heckel.ntfy.msg
import android.content.Context import android.content.Context
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Action 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.util.Log import io.heckel.ntfy.util.Log
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request .callTimeout(60, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS)
.build() .build()
private val notifier = NotificationService(context)
private val broadcaster = BroadcastService(context)
private lateinit var repository: Repository
private lateinit var subscription: Subscription
private lateinit var notification: Notification
private lateinit var action: Action
override fun doWork(): Result { override fun doWork(): Result {
if (context.applicationContext !is Application) return Result.failure() if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString(INPUT_DATA_NOTIFICATION_ID) ?: return Result.failure() val notificationId = inputData.getString(INPUT_DATA_NOTIFICATION_ID) ?: return Result.failure()
val actionId = inputData.getString(INPUT_DATA_ACTION_ID) ?: return Result.failure() val actionId = inputData.getString(INPUT_DATA_ACTION_ID) ?: return Result.failure()
val app = context.applicationContext as Application val app = context.applicationContext as Application
val notification = app.repository.getNotification(notificationId) ?: return Result.failure()
val action = notification.actions?.first { it.id == actionId } ?: return Result.failure() repository = app.repository
notification = repository.getNotification(notificationId) ?: return Result.failure()
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
action = notification.actions?.first { it.id == actionId } ?: return Result.failure()
Log.d(TAG, "Executing action $action for notification $notification") Log.d(TAG, "Executing action $action for notification $notification")
http(context, action) try {
when (action.action) {
ACTION_HTTP -> performHttpAction(action)
ACTION_BROADCAST -> performBroadcastAction(action)
}
} catch (e: Exception) {
Log.w(TAG, "Error executing action: ${e.message}", e)
save(action.copy(
progress = ACTION_PROGRESS_FAILED,
error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message)
))
}
return Result.success() return Result.success()
} }
private fun performHttpAction(action: Action) {
save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null))
fun http(context: Context, action: Action) { // FIXME Worker!
val url = action.url ?: return val url = action.url ?: return
val method = action.method ?: "GET" val method = action.method ?: "POST" // (not GET, because POST as a default makes more sense!)
val body = action.body ?: "" val body = action.body ?: ""
Log.d(TAG, "HTTP POST againt ${action.url}") val builder = Request.Builder()
val request = Request.Builder()
.url(url) .url(url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.method(method, body.toRequestBody()) .method(method, body.toRequestBody())
.build() .addHeader("User-Agent", ApiService.USER_AGENT)
action.headers?.forEach { (key, value) ->
builder.addHeader(key, value)
}
val request = builder.build()
Log.d(TAG, "Executing HTTP request: ${method.uppercase(Locale.getDefault())} ${action.url}")
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null))
return return
} }
throw Exception("Unexpected server response ${response.code}") throw Exception("HTTP ${response.code}")
} }
} }
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 }
val newNotification = notification.copy(actions = newActions)
action = newAction
notification = newNotification
notifier.update(subscription, notification)
repository.updateNotification(notification)
}
companion object { companion object {
const val INPUT_DATA_NOTIFICATION_ID = "notificationId" const val INPUT_DATA_NOTIFICATION_ID = "notificationId"
const val INPUT_DATA_ACTION_ID = "actionId" const val INPUT_DATA_ACTION_ID = "actionId"

View file

@ -217,10 +217,10 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String {
val name = attachment.name val name = attachment.name
val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99 val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED) val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED)
val failed = !exists && attachment.progress == PROGRESS_FAILED val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000 val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000
val infos = mutableListOf<String>() val infos = mutableListOf<String>()
@ -357,7 +357,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
if (!deleted) throw Exception("no rows deleted") if (!deleted) throw Exception("no rows deleted")
val newAttachment = attachment.copy( val newAttachment = attachment.copy(
contentUri = null, contentUri = null,
progress = PROGRESS_DELETED progress = ATTACHMENT_PROGRESS_DELETED
) )
val newNotification = notification.copy(attachment = newAttachment) val newNotification = notification.copy(attachment = newAttachment)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {

View file

@ -5,7 +5,7 @@ import android.net.Uri
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.PROGRESS_DELETED import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.ui.DetailAdapter
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
@ -48,7 +48,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
} }
val newAttachment = attachment.copy( val newAttachment = attachment.copy(
contentUri = null, contentUri = null,
progress = PROGRESS_DELETED progress = ATTACHMENT_PROGRESS_DELETED
) )
val newNotification = notification.copy(attachment = newAttachment) val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification) repository.updateNotification(newNotification)

View file

@ -220,6 +220,7 @@
<string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string> <string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string>
<string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, downloaded</string> <string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, downloaded</string>
<string name="notification_popup_file_download_failed">%1$s\nFile: %2$s, download failed</string> <string name="notification_popup_file_download_failed">%1$s\nFile: %2$s, download failed</string>
<string name="notification_popup_user_action_failed">"%1$s" failed: %2$s</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>