Actions WIP
This commit is contained in:
parent
686616d4d2
commit
79c0e91e8d
11 changed files with 153 additions and 74 deletions
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Reference in a new issue