Merge pull request #26 from binwiederhier/actions

WIP Actions
This commit is contained in:
Philipp C. Heckel 2022-04-22 15:05:45 -04:00 committed by GitHub
commit 3fb8124a67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 817 additions and 118 deletions

View file

@ -81,7 +81,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// Firebase, sigh ... (only Google Play)
playImplementation 'com.google.firebase:firebase-messaging:23.0.2'
playImplementation 'com.google.firebase:firebase-messaging:23.0.3'
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.3.0-alpha02"

View file

@ -0,0 +1,302 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb",
"entities": [
{
"tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instant",
"columnName": "instant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mutedUntil",
"columnName": "mutedUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
}
],
"foreignKeys": []
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscriptionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encoding",
"columnName": "encoding",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "click",
"columnName": "click",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actions",
"columnName": "actions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachment.name",
"columnName": "attachment_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.type",
"columnName": "attachment_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.size",
"columnName": "attachment_size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachment.expires",
"columnName": "attachment_expires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachment.url",
"columnName": "attachment_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.contentUri",
"columnName": "attachment_contentUri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.progress",
"columnName": "attachment_progress",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"subscriptionId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"baseUrl"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "exception",
"columnName": "exception",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c1b4f54d1d3111dc5c8f02e8fa960ceb')"
]
}
}

View file

@ -123,7 +123,7 @@
<!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup -->
<receiver
android:name=".msg.NotificationService$DownloadBroadcastReceiver"
android:name=".msg.NotificationService$UserActionBroadcastReceiver"
android:enabled="true"
android:exported="false">
</receiver>

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,6 +153,7 @@ class Backuper(val context: Context) {
priority = n.priority,
tags = n.tags,
click = n.click,
actions = actions,
attachment = attachment,
deleted = n.deleted
))
@ -201,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,
@ -224,6 +264,7 @@ class Backuper(val context: Context) {
priority = n.priority,
tags = n.tags,
click = n.click,
actions = actions,
attachment = attachment,
deleted = n.deleted
)
@ -290,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
@ -304,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

@ -4,8 +4,10 @@ import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.heckel.ntfy.util.shortUrl
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import java.lang.reflect.Type
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
data class Subscription(
@ -55,6 +57,7 @@ data class Notification(
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String,
@ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
@ColumnInfo(name = "actions") val actions: List<Action>?,
@Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@ -70,14 +73,48 @@ data class Attachment(
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
) {
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 PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_FAILED = -3
const val PROGRESS_DELETED = -4
const val PROGRESS_DONE = 100
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
data class Action(
@ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
@ColumnInfo(name = "action") val action: String, // "view", "http" or "broadcast"
@ColumnInfo(name = "label") val label: String,
@ColumnInfo(name = "url") val url: String?, // used in "view" and "http" actions
@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
)
const val ACTION_PROGRESS_ONGOING = 1
const val ACTION_PROGRESS_SUCCESS = 2
const val ACTION_PROGRESS_FAILED = 3
class Converters {
private val gson = Gson()
@TypeConverter
fun toActionList(value: String?): List<Action>? {
val listType: Type = object : TypeToken<List<Action>?>() {}.type
return gson.fromJson(value, listType)
}
@TypeConverter
fun fromActionList(list: List<Action>?): String {
return gson.toJson(list)
}
}
@Entity
data class User(
@ -101,7 +138,8 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 9)
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 10)
@TypeConverters(Converters::class)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@ -124,6 +162,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@ -199,6 +238,12 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
}
}
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT")
}
}
}
}

View file

@ -1,6 +1,5 @@
package io.heckel.ntfy.msg
import android.net.Uri
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification
@ -9,7 +8,6 @@ import io.heckel.ntfy.util.*
import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.net.URL
import java.net.URLEncoder
import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit

View file

@ -2,8 +2,8 @@ package io.heckel.ntfy.msg
import android.content.Context
import android.content.Intent
import android.util.Base64
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
* in order to facilitate tasks app integrations.
*/
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()
intent.action = MESSAGE_RECEIVED_ACTION
intent.putExtra("id", notification.id)
@ -34,7 +34,17 @@ class BroadcastService(private val ctx: Context) {
intent.putExtra("muted", muted)
intent.putExtra("muted_str", muted.toString())
Log.d(TAG, "Sending 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 = action.intent ?: USER_ACTION_ACTION
action.extras?.forEach { (key, value) ->
intent.putExtra(key, value)
}
Log.d(TAG, "Sending user action intent broadcast: ${intent.action} with extras ${intent.extras}")
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
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 USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
}
}

View file

@ -13,30 +13,27 @@ import io.heckel.ntfy.util.Log
* The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice apparently.
*/
class DownloadManager {
companion object {
private const val TAG = "NtfyDownloadManager"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
object DownloadManager {
private const val TAG = "NtfyDownloadManager"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
fun enqueue(context: Context, notificationId: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId
Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setInputData(workDataOf(
DownloadWorker.INPUT_DATA_ID to notificationId,
DownloadWorker.INPUT_DATA_USER_ACTION to userAction
))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName)
}
fun enqueue(context: Context, notificationId: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId
Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setInputData(workDataOf(
DownloadWorker.INPUT_DATA_ID to notificationId,
DownloadWorker.INPUT_DATA_USER_ACTION to userAction
))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName)
}
}

View file

@ -91,13 +91,13 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
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()
}
val progress = if (attachment.size != null && attachment.size!! > 0) {
(bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
} else {
PROGRESS_INDETERMINATE
ATTACHMENT_PROGRESS_INDETERMINATE
}
save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis()
@ -114,7 +114,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
save(attachment.copy(
size = bytesCopied,
contentUri = uri.toString(),
progress = PROGRESS_DONE
progress = ATTACHMENT_PROGRESS_DONE
))
}
} catch (e: Exception) {
@ -155,7 +155,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
private fun failed(e: Exception) {
Log.w(TAG, "Attachment download failed", e)
save(attachment.copy(progress = PROGRESS_FAILED))
save(attachment.copy(progress = ATTACHMENT_PROGRESS_FAILED))
maybeDeleteFile()
}

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.msg
import androidx.annotation.Keep
import io.heckel.ntfy.db.Action
/* This annotation ensures that proguard still works in production builds,
* see https://stackoverflow.com/a/62753300/1440785 */
@ -13,6 +14,7 @@ data class Message(
val priority: Int?,
val tags: List<String>?,
val click: String?,
val actions: List<MessageAction>?,
val title: String?,
val message: String,
val encoding: String?,
@ -28,4 +30,17 @@ data class MessageAttachment(
val url: String,
)
@Keep
data class MessageAction(
val id: String,
val action: String,
val label: String, // "view", "broadcast" or "http"
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,7 +1,6 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.util.Base64
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@ -35,7 +34,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
notifier.display(subscription, notification)
}
if (broadcast) {
broadcaster.send(subscription, notification, muted)
broadcaster.sendMessage(subscription, notification, muted)
}
if (distribute) {
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->

View file

@ -1,11 +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.toPriority
import java.lang.reflect.Type
class NotificationParser {
private val gson = Gson()
@ -29,6 +31,11 @@ class NotificationParser {
url = message.attachment.url,
)
} 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.intent, a.extras, null, null)
}
} else null
val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
@ -39,6 +46,7 @@ class NotificationParser {
priority = toPriority(message.priority),
tags = joinTags(message.tags),
click = message.click ?: "",
actions = actions,
attachment = attachment,
notificationId = notificationId,
deleted = false
@ -46,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

@ -1,7 +1,12 @@
package io.heckel.ntfy.msg
import android.app.*
import android.content.*
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.net.Uri
@ -10,12 +15,11 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.db.*
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.ui.Colors
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.*
import java.util.*
class NotificationService(val context: Context) {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -65,6 +69,7 @@ class NotificationService(val context: Context) {
maybeAddBrowseAction(builder, notification)
maybeAddDownloadAction(builder, notification)
maybeAddCancelAction(builder, notification)
maybeAddUserActions(builder, notification)
maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build())
@ -88,43 +93,43 @@ class NotificationService(val context: Context) {
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder
.setContentText(formatMessage(notification))
.setContentText(maybeAppendActionErrors(formatMessage(notification), notification))
.setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null))
} catch (_: Exception) {
val message = formatMessageMaybeWithAttachmentInfo(notification)
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
} else {
val message = formatMessageMaybeWithAttachmentInfo(notification)
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
}
private fun formatMessageMaybeWithAttachmentInfo(notification: Notification): String {
private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String {
val message = formatMessage(notification)
val attachment = notification.attachment ?: return message
val infos = if (attachment.size != null) {
val attachmentInfos = if (attachment.size != null) {
"${attachment.name}, ${formatBytes(attachment.size)}"
} else {
attachment.name
}
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) {
return context.getString(R.string.notification_popup_file_download_successful, message, infos)
if (attachment.progress == ATTACHMENT_PROGRESS_DONE) {
return context.getString(R.string.notification_popup_file_download_successful, message, attachmentInfos)
}
if (attachment.progress == PROGRESS_FAILED) {
return context.getString(R.string.notification_popup_file_download_failed, message, infos)
if (attachment.progress == ATTACHMENT_PROGRESS_FAILED) {
return context.getString(R.string.notification_popup_file_download_failed, message, attachmentInfos)
}
return context.getString(R.string.notification_popup_file, message, infos)
return context.getString(R.string.notification_popup_file, message, attachmentInfos)
}
private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) {
@ -133,7 +138,7 @@ class NotificationService(val context: Context) {
} else {
try {
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)
} catch (e: Exception) {
builder.setContentIntent(detailActivityIntent(subscription))
@ -153,50 +158,95 @@ class NotificationService(val context: Context) {
private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri)
val intent = Intent(Intent.ACTION_VIEW, contentUri)
intent.setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val intent = Intent(Intent.ACTION_VIEW, contentUri).apply {
setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
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())
}
}
private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) {
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
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())
}
}
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java)
intent.putExtra("action", DOWNLOAD_ACTION_START)
intent.putExtra("id", notification.id)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
if (notification.attachment?.contentUri == null && listOf(ATTACHMENT_PROGRESS_NONE, ATTACHMENT_PROGRESS_FAILED).contains(notification.attachment?.progress)) {
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
}
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())
}
}
private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java)
intent.putExtra("action", DOWNLOAD_ACTION_CANCEL)
intent.putExtra("id", notification.id)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
}
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())
}
}
class DownloadBroadcastReceiver : BroadcastReceiver() {
private fun maybeAddUserActions(builder: NotificationCompat.Builder, notification: Notification) {
notification.actions?.forEach { action ->
when (action.action.lowercase(Locale.getDefault())) {
ACTION_VIEW -> maybeAddViewUserAction(builder, action)
ACTION_HTTP, ACTION_BROADCAST -> maybeAddHttpOrBroadcastUserAction(builder, notification, action)
}
}
}
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 {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
} catch (e: Exception) {
Log.w(TAG, "Unable to add open user action", e)
}
}
private fun maybeAddHttpOrBroadcastUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_USER_ACTION)
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
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 = formatActionLabel(action)
builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build())
}
class UserActionBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getStringExtra("id") ?: return
val action = intent.getStringExtra("action") ?: return
when (action) {
DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id, userAction = true)
DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id)
val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
when (type) {
BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true)
BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId)
BROADCAST_TYPE_USER_ACTION -> {
val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return
UserActionManager.enqueue(context, notificationId, actionId)
}
}
}
}
@ -262,9 +312,19 @@ class NotificationService(val context: Context) {
}
companion object {
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 DOWNLOAD_ACTION_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
private const val DOWNLOAD_ACTION_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -0,0 +1,32 @@
package io.heckel.ntfy.msg
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.util.Log
/**
* Trigger user actions clicked from notification popups.
*
* The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice, apparently.
*/
object UserActionManager {
private const val TAG = "NtfyUserActionEx"
private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_"
fun enqueue(context: Context, notificationId: String, actionId: String) {
val workManager = WorkManager.getInstance(context)
val workName = WORK_NAME_PREFIX + notificationId + "_" + actionId
Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, action $actionId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java)
.setInputData(workDataOf(
UserActionWorker.INPUT_DATA_NOTIFICATION_ID to notificationId,
UserActionWorker.INPUT_DATA_ACTION_ID to actionId,
))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
}

View file

@ -0,0 +1,107 @@
package io.heckel.ntfy.msg
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.R
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.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.*
import java.util.concurrent.TimeUnit
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(60, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.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 {
if (context.applicationContext !is Application) 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 app = context.applicationContext as Application
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")
try {
when (action.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)
save(action.copy(
progress = ACTION_PROGRESS_FAILED,
error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message)
))
}
return Result.success()
}
private fun performBroadcastAction(action: Action) {
broadcaster.sendUserAction(action)
}
private fun performHttpAction(action: Action) {
save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null))
val url = action.url ?: return
val method = action.method ?: "POST" // (not GET, because POST as a default makes more sense!)
val body = action.body ?: ""
val builder = Request.Builder()
.url(url)
.method(method, body.toRequestBody())
.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 ->
if (response.isSuccessful) {
save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null))
return
}
throw Exception("HTTP ${response.code}")
}
}
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 {
const val INPUT_DATA_NOTIFICATION_ID = "notificationId"
const val INPUT_DATA_ACTION_ID = "actionId"
private const val TAG = "NtfyUserActWrk"
}
}

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

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

View file

@ -186,7 +186,7 @@
<string name="settings_advanced_export_logs_entry_copy_scrubbed">Копиране в междинната памет (цензурирано)</string>
<string name="main_banner_json_stream_text">От юни 2022 г. за връзка със сървърите на ntfy ще се използва WebSockets. Не забравяйте да настроите собствения сървър да го поддържа. За да проверите дали поддръжката на WebSocket работи, разрешете я в Настройки, в раздел Протокол за връзка.</string>
<string name="settings_advanced_connection_protocol_summary_jsonhttp">За свързване със сървъра се използва поток от JSON през HTTP. Методът е остарял и ще бъде премахнат през месец юни 2022 год.</string>
<string name="detail_test_message">Това е пробно известие от приложението Ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.</string>
<string name="detail_test_message">Това е пробно известие от приложението ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.</string>
<string name="detail_test_title">Проба: Ако желаете можете да сложите заглавие</string>
<string name="detail_test_message_error_unauthorized_user">Грешка при изпращане: Потребителят „%1$s“ няма достъп.</string>
<string name="settings_notifications_min_priority_summary_x_or_higher">Показват се известията с приоритет %1$d (%2$s) или по-висок</string>

View file

@ -205,7 +205,7 @@
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON-strøm over HTTP</string>
<string name="settings_advanced_connection_protocol_entry_ws">Vev-sockets</string>
<string name="user_dialog_description_add">Du kan legge til en bruker her som du kan tilknytte et gitt emne senere.</string>
<string name="settings_about_version_format">Ntfy %1$s (%2$s)</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
<string name="user_dialog_password_hint_edit">Passord (uendret hvis tomt)</string>
<string name="user_dialog_button_add">Legg til bruker</string>
<string name="user_dialog_button_cancel">Avbryt</string>

View file

@ -47,7 +47,7 @@
<string name="add_dialog_login_error_not_authorized">登录失败。用户 %1$s 无权访问。</string>
<string name="add_dialog_login_new_user">新用户</string>
<string name="detail_no_notifications_text">目前还没有关于此主题的通知。</string>
<string name="main_banner_json_stream_text">请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 Ntfy 服务器的推送。</string>
<string name="main_banner_json_stream_text">请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 ntfy 服务器的推送。</string>
<string name="main_banner_json_stream_button_remind_later">稍后再问</string>
<string name="main_banner_json_stream_button_dismiss">暂时不管</string>
<string name="main_banner_json_stream_button_learn_more">详情</string>
@ -56,7 +56,7 @@
<string name="add_dialog_topic_name_hint">主题名称比如phils_alerts</string>
<string name="detail_how_to_link">详细的说明请见 ntfy.sh 和帮助文档。</string>
<string name="detail_clear_dialog_message">您确认要删除这个主题下的所有通知吗?</string>
<string name="detail_test_message">这是 Ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知通知的样式可能有变化。</string>
<string name="detail_test_message">这是 ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知通知的样式可能有变化。</string>
<string name="detail_test_message_error_unauthorized_user">无法发送消息:用户 %1$s 无权发布。</string>
<string name="detail_item_menu_download">下载文件</string>
<string name="detail_item_menu_save_file">保存文件</string>

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_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_user_action_failed">"%1$s" failed: %2$s</string>
<!-- Settings -->
<string name="settings_title">Settings</string>

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,12 +134,13 @@ class FirebaseService : FirebaseMessagingService() {
priority = toPriority(priority),
tags = tags ?: "",
click = click ?: "",
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)
}
}

View file

@ -1,6 +1,6 @@
Изпращайте известия към телефона си от всеки скрипт на Bash или PowerShell, или от вашето приложение чрез заявки по PUT/POST, напр. с curl или Invoke-WebRequest.
Ntfy е клиент за Android за https://ntfy.sh, безплатна услуга с отворен код за абониране и публикуване на основата на HTTP. Абонирайте се за дадена тема в приложението, а после публикувайте съобщения чрез семпъл ППИ на HTTP.
ntfy е клиент за Android за https://ntfy.sh, безплатна услуга с отворен код за абониране и публикуване на основата на HTTP. Абонирайте се за дадена тема в приложението, а после публикувайте съобщения чрез семпъл ППИ на HTTP.
Употреба:
* Получавайте известия, когато някакъв дълъг процес завърши

View file

@ -1 +1 @@
Ntfy - PUT/POST към телефон
ntfy - PUT/POST към телефон

View file

@ -1,6 +1,6 @@
Send notifications to your phone from any Bash or PowerShell script, or from your own app using PUT/POST requests, e.g. via curl on Linux or Invoke-WebRequest.
Ntfy is an Android client for https://ntfy.sh, a free and open source HTTP-based pub-sub service. You can subscribe to topics in this app, and then publish messages via a simple HTTP API.
ntfy is an Android client for https://ntfy.sh, a free and open source HTTP-based pub-sub service. You can subscribe to topics in this app, and then publish messages via a simple HTTP API.
Uses:
* Notify yourself when a long-running process is done

View file

@ -1 +1 @@
Ntfy - PUT/POST to your phone
ntfy - PUT/POST to your phone

View file

@ -1,6 +1,6 @@
Envíe notificaciones a su teléfono desde cualquier script de Bash o PowerShell, o desde tu propia aplicación utilizando peticiones PUT/POST, por ejemplo, mediante curl en Linux o Invoke-WebRequest.
Ntfy es un cliente Android para https://ntfy.sh, un servicio pub-sub basado en HTTP, gratuito y de código abierto. Puede suscribirse a tópicos en esta aplicación, y luego publicar mensajes a través de una simple API HTTP.
ntfy es un cliente Android para https://ntfy.sh, un servicio pub-sub basado en HTTP, gratuito y de código abierto. Puede suscribirse a tópicos en esta aplicación, y luego publicar mensajes a través de una simple API HTTP.
Usos:
* Notificarse a sí mismo cuando un proceso de larga duración ha terminado

View file

@ -1 +1 @@
Ntfy - PUT/POST a su teléfono
ntfy - PUT/POST a su teléfono

View file

@ -1,6 +1,6 @@
スマホに通知を送信します。BashやPowerShellスクリプト、あなたの独自アプリから、例えばLinuxのcurlやInvoke-WebRequestを介したPUT/POSTリクエストで送信させることができます。
Ntfyは無料でオープンソースなHTTPベースのpub-subサービス ( https://ntfy.sh ) のアンドロイドクライアントです。アプリでトピックを購読して、シンプルなHTTP APIでメッセージを送信する事ができます。
ntfyは無料でオープンソースなHTTPベースのpub-subサービス ( https://ntfy.sh ) のアンドロイドクライアントです。アプリでトピックを購読して、シンプルなHTTP APIでメッセージを送信する事ができます。
用途:
* 長時間処理のプロセス完了時に自分に通知

View file

@ -1 +1 @@
Ntfy - スマホにPUT/POST通知しよう
ntfy - スマホにPUT/POST通知しよう

View file

@ -1,6 +1,6 @@
Send merknader til din mobilenhet fra Bash eller PowerShell-skript, eller fra ditt eget program som bruker PUT/POST-forespørsler, f.eks. via cURL på Linux|GNU, eller Invoke-WebRequest.
Ntfy er en Android-klient for https://ntfy.sh, en gratis og åpen HTTP-basert pub-sub-tjeneste. Du kan abonnere på emner i dette programmet, og kan så publisere meldinger ved et enkelt HTTP-API.
ntfy er en Android-klient for https://ntfy.sh, en gratis og åpen HTTP-basert pub-sub-tjeneste. Du kan abonnere på emner i dette programmet, og kan så publisere meldinger ved et enkelt HTTP-API.
Bruk:
* Gi deg selv en merknad når en tidkrevende prosess er ferdig

View file

@ -1 +1 @@
Ntfy — PUT/POST til din mobil
ntfy — PUT/POST til din mobil

View file

@ -1,6 +1,6 @@
Отправляйте уведомления на ваш телефон из любого Bash или PowerShell скрипта, или же собственного приложения с использованием PUT/POST запросов, например, через curl на Linux или Invoke-WebRequest.
Ntfy является Android клиентом для https;//ntfy.sh, бесплатной основанной на HTTP издатель-подписчик (pub-sub) службе с открытым исходным кодом.
ntfy является Android клиентом для https;//ntfy.sh, бесплатной основанной на HTTP издатель-подписчик (pub-sub) службе с открытым исходным кодом.
Возможные применения:
* Уведомите себя при завершении длительного процесса

View file

@ -1 +1 @@
Ntfy - PUT/POST на ваш телефон
ntfy - PUT/POST на ваш телефон

View file

@ -1,6 +1,6 @@
Herhangi bir Bash veya PowerShell betiğinden veya kendi uygulamanızdan PUT/POST isteklerini kullanarak telefonunuza bildirimler gönderin, örn. Linux curl ile veya Invoke-WebRequest aracılığıyla.
Ntfy, özgür ve açık kaynaklı HTTP tabanlı bir yayın-abone hizmeti olan https://ntfy.sh için bir Android istemcisidir. Bu uygulamadaki konulara abone olabilir ve ardından basit bir HTTP API aracılığıyla mesajlar yayınlayabilirsiniz.
ntfy, özgür ve açık kaynaklı HTTP tabanlı bir yayın-abone hizmeti olan https://ntfy.sh için bir Android istemcisidir. Bu uygulamadaki konulara abone olabilir ve ardından basit bir HTTP API aracılığıyla mesajlar yayınlayabilirsiniz.
Kullanım Alanları:
* Uzun süren bir işlem bittiğinde kendinize haber verin

View file

@ -1 +1 @@
Ntfy - Telefonunuza PUT/POST
ntfy - Telefonunuza PUT/POST

View file

@ -1,2 +1,2 @@
rootProject.name='Ntfy'
rootProject.name='ntfy'
include ':app'