diff --git a/app/build.gradle b/app/build.gradle index 7230575..60245ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/schemas/io.heckel.ntfy.db.Database/10.json b/app/schemas/io.heckel.ntfy.db.Database/10.json new file mode 100644 index 0000000..3fd983f --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/10.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9fdb969..e5be012 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,7 +123,7 @@ diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 52720d3..5c5ea2c 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.backup import android.content.Context import android.net.Uri +import androidx.room.ColumnInfo import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.stream.JsonReader @@ -109,6 +110,25 @@ class Backuper(val context: Context) { } notifications.forEach { n -> try { + val actions = if (n.actions != null) { + n.actions.map { a -> + io.heckel.ntfy.db.Action( + id = a.id, + action = a.action, + label = a.label, + url = a.url, + method = a.method, + headers = a.headers, + body = a.body, + intent = a.intent, + extras = a.extras, + progress = a.progress, + error = a.error + ) + } + } else { + null + } val attachment = if (n.attachment != null) { io.heckel.ntfy.db.Attachment( name = n.attachment.name, @@ -133,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 { 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?, val attachment: Attachment?, val deleted: Boolean ) +data class Action( + val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager + val action: String, // "view", "http" or "broadcast" + val label: String, + val url: String?, // used in "view" and "http" actions + val method: String?, // used in "http" action + val headers: Map?, // used in "http" action + val body: String?, // used in "http" action + val intent: String?, // used in "broadcast" action + val extras: Map?, // used in "broadcast" action + val progress: Int?, // used to indicate progress in popup + val error: String? // used to indicate errors in popup +) + data class Attachment( val name: String, // Filename val type: String?, // MIME type @@ -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, diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index ed3d550..2c6545b 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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?, @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?, // used in "http" action + @ColumnInfo(name = "body") val body: String?, // used in "http" action + @ColumnInfo(name = "intent") val intent: String?, // used in "broadcast" action + @ColumnInfo(name = "extras") val extras: Map?, // used in "broadcast" action + @ColumnInfo(name = "progress") val progress: Int?, // used to indicate progress in popup + @ColumnInfo(name = "error") val error: String?, // used to indicate errors in popup +) + +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? { + val listType: Type = object : TypeToken?>() {}.type + return gson.fromJson(value, listType) + } + + @TypeConverter + fun fromActionList(list: List?): 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") + } + } } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index d3f4eca..0693834 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index d39bb6c..e5f46fd 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -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" } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt index 4ff45e2..4a6739c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt @@ -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) } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt index a8eed41..df97a81 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt @@ -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() } diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 51ed150..e2fcd76 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -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?, val click: String?, + val actions: List?, 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?, // used in "http" action + val body: String?, // used in "http" action + val intent: String?, // used in "broadcast" action + val extras: Map?, // used in "broadcast" action +) + const val MESSAGE_ENCODING_BASE64 = "base64" diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index bfc4f86..e1afdf6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -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 -> diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 48b873b..b855d24 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -1,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? { + val listType: Type = object : TypeToken?>() {}.type + val messageActions: List? = gson.fromJson(s, listType) + return messageActions?.map { a -> + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null) + } + } + data class NotificationWithTopic(val topic: String, val notification: Notification) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index ceafd4c..f612993 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt new file mode 100644 index 0000000..374db0d --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt @@ -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) + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt new file mode 100644 index 0000000..d505813 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -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" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 4ff5e63..bb64ff1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -25,6 +25,8 @@ import io.heckel.ntfy.R import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadWorker +import io.heckel.ntfy.msg.NotificationService +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -81,7 +83,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo val unmatchedTags = unmatchedTags(splitTags(notification.tags)) dateView.text = formatDateShort(notification.timestamp) - messageView.text = formatMessage(notification) + messageView.text = maybeAppendActionErrors(formatMessage(notification), notification) newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE itemView.setOnClickListener { onClick(notification) } itemView.setOnLongClickListener { onLongClick(notification); true } @@ -179,6 +181,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo val attachment = notification.attachment // May be null val hasAttachment = attachment != null val hasClickLink = notification.click != "" + val hasUserActions = notification.actions?.isNotEmpty() ?: false val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) val openItem = popup.menu.findItem(R.id.detail_item_menu_open) @@ -199,6 +202,12 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo if (hasClickLink) { copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) } } + if (notification.actions != null && notification.actions.isNotEmpty()) { + notification.actions.forEach { action -> + val actionItem = popup.menu.add(formatActionLabel(action)) + actionItem.setOnMenuItemClickListener { runAction(context, notification, action) } + } + } openItem.isVisible = hasAttachment && exists downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress deleteItem.isVisible = hasAttachment && exists @@ -208,7 +217,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo copyContentsItem.isVisible = notification.click != "" val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible && !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible - && !copyContentsItem.isVisible + && !copyContentsItem.isVisible && !hasUserActions if (noOptions) { return null } @@ -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() @@ -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() { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index f915215..cea4c86 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -23,9 +23,7 @@ import android.widget.ImageView import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate import io.heckel.ntfy.R -import io.heckel.ntfy.db.Notification -import io.heckel.ntfy.db.Repository -import io.heckel.ntfy.db.Subscription +import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -185,6 +183,27 @@ fun formatTitle(notification: Notification): String { } } +fun formatActionLabel(action: Action): String { + return when (action.progress) { + ACTION_PROGRESS_ONGOING -> action.label + " …" + ACTION_PROGRESS_SUCCESS -> action.label + " ✔️" + ACTION_PROGRESS_FAILED -> action.label + " ❌️" + else -> action.label + } +} + +fun maybeAppendActionErrors(message: String, notification: Notification): String { + val actionErrors = notification.actions + .orEmpty() + .mapNotNull { action -> action.error } + .joinToString("\n") + if (actionErrors.isEmpty()) { + return message + } else { + return "${message}\n\n${actionErrors}" + } +} + // Checks in the most horrible way if a content URI exists; I couldn't find a better way fun fileExists(context: Context, contentUri: String?): Boolean { return try { diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index ee3772b..bd2c642 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -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) diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index b2df40a..247767b 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -186,7 +186,7 @@ Копиране в междинната памет (цензурирано) От юни 2022 г. за връзка със сървърите на ntfy ще се използва WebSockets. Не забравяйте да настроите собствения сървър да го поддържа. За да проверите дали поддръжката на WebSocket работи, разрешете я в Настройки, в раздел Протокол за връзка. За свързване със сървъра се използва поток от JSON през HTTP. Методът е остарял и ще бъде премахнат през месец юни 2022 год. - Това е пробно известие от приложението Ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин. + Това е пробно известие от приложението ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин. Проба: Ако желаете можете да сложите заглавие Грешка при изпращане: Потребителят „%1$s“ няма достъп. Показват се известията с приоритет %1$d (%2$s) или по-висок diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 81415a5..c38aa24 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -205,7 +205,7 @@ JSON-strøm over HTTP Vev-sockets Du kan legge til en bruker her som du kan tilknytte et gitt emne senere. - Ntfy %1$s (%2$s) + ntfy %1$s (%2$s) Passord (uendret hvis tomt) Legg til bruker Avbryt diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4ed177d..0e7ca2c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -47,7 +47,7 @@ 登录失败。用户 %1$s 无权访问。 新用户 目前还没有关于此主题的通知。 - 请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 Ntfy 服务器的推送。 + 请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 ntfy 服务器的推送。 稍后再问 暂时不管 详情 @@ -56,7 +56,7 @@ 主题名称,比如:phils_alerts 详细的说明请见 ntfy.sh 和帮助文档。 您确认要删除这个主题下的所有通知吗? - 这是 Ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。 + 这是 ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。 无法发送消息:用户 %1$s 无权发布。 下载文件 保存文件 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6562e40..6c01467 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,7 @@ Downloading %1$s, %2$d%%\n%3$s %1$s\nFile: %2$s, downloaded %1$s\nFile: %2$s, download failed + "%1$s" failed: %2$s Settings diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 9e6d883..4d44d2a 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -4,7 +4,7 @@ The translatable="false" attribute is just an additional safety. --> - Ntfy + ntfy https://ntfy.sh diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 2506cee..ec1e130 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -13,6 +13,7 @@ import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 import io.heckel.ntfy.msg.NotificationDispatcher +import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.topicShortUrl @@ -27,6 +28,7 @@ class FirebaseService : FirebaseMessagingService() { private val dispatcher by lazy { NotificationDispatcher(this, repository) } private val job = SupervisorJob() private val messenger = FirebaseMessenger() + private val parser = NotificationParser() override fun onMessageReceived(remoteMessage: RemoteMessage) { // Init log (this is done in all entrypoints) @@ -88,6 +90,7 @@ class FirebaseService : FirebaseMessagingService() { val priority = data["priority"]?.toIntOrNull() val tags = data["tags"] val click = data["click"] + val actions = data["actions"] // JSON array as string, sigh ... val encoding = data["encoding"] val attachmentName = data["attachment_name"] ?: "attachment.bin" val attachmentType = data["attachment_type"] @@ -131,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) } } diff --git a/fastlane/metadata/android/bg/full_description.txt b/fastlane/metadata/android/bg/full_description.txt index 65a3cf7..4d992bf 100644 --- a/fastlane/metadata/android/bg/full_description.txt +++ b/fastlane/metadata/android/bg/full_description.txt @@ -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. Употреба: * Получавайте известия, когато някакъв дълъг процес завърши diff --git a/fastlane/metadata/android/bg/title.txt b/fastlane/metadata/android/bg/title.txt index 6d702fd..60a32d6 100644 --- a/fastlane/metadata/android/bg/title.txt +++ b/fastlane/metadata/android/bg/title.txt @@ -1 +1 @@ -Ntfy - PUT/POST към телефон +ntfy - PUT/POST към телефон diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index d2b3b93..80bc551 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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 diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt index 0d6e441..d33c752 100644 --- a/fastlane/metadata/android/en-US/title.txt +++ b/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Ntfy - PUT/POST to your phone +ntfy - PUT/POST to your phone diff --git a/fastlane/metadata/android/es/full_description.txt b/fastlane/metadata/android/es/full_description.txt index 6429bbd..ffdc146 100644 --- a/fastlane/metadata/android/es/full_description.txt +++ b/fastlane/metadata/android/es/full_description.txt @@ -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 diff --git a/fastlane/metadata/android/es/title.txt b/fastlane/metadata/android/es/title.txt index 590f4a9..8a12a7d 100644 --- a/fastlane/metadata/android/es/title.txt +++ b/fastlane/metadata/android/es/title.txt @@ -1 +1 @@ -Ntfy - PUT/POST a su teléfono +ntfy - PUT/POST a su teléfono diff --git a/fastlane/metadata/android/ja/full_description.txt b/fastlane/metadata/android/ja/full_description.txt index a41eb27..3d1ab6c 100644 --- a/fastlane/metadata/android/ja/full_description.txt +++ b/fastlane/metadata/android/ja/full_description.txt @@ -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でメッセージを送信する事ができます。 用途: * 長時間処理のプロセス完了時に自分に通知 diff --git a/fastlane/metadata/android/ja/title.txt b/fastlane/metadata/android/ja/title.txt index 91e0cff..4d0abda 100644 --- a/fastlane/metadata/android/ja/title.txt +++ b/fastlane/metadata/android/ja/title.txt @@ -1 +1 @@ -Ntfy - スマホにPUT/POST通知しよう +ntfy - スマホにPUT/POST通知しよう diff --git a/fastlane/metadata/android/nb-NO/full_description.txt b/fastlane/metadata/android/nb-NO/full_description.txt index 6d01bde..85b9d50 100644 --- a/fastlane/metadata/android/nb-NO/full_description.txt +++ b/fastlane/metadata/android/nb-NO/full_description.txt @@ -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 diff --git a/fastlane/metadata/android/nb-NO/title.txt b/fastlane/metadata/android/nb-NO/title.txt index 066394e..3443e47 100644 --- a/fastlane/metadata/android/nb-NO/title.txt +++ b/fastlane/metadata/android/nb-NO/title.txt @@ -1 +1 @@ -Ntfy — PUT/POST til din mobil +ntfy — PUT/POST til din mobil diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt index 8c10dd4..b3045c2 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/fastlane/metadata/android/ru/full_description.txt @@ -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) службе с открытым исходным кодом. Возможные применения: * Уведомите себя при завершении длительного процесса diff --git a/fastlane/metadata/android/ru/title.txt b/fastlane/metadata/android/ru/title.txt index f566fc7..70d5de0 100644 --- a/fastlane/metadata/android/ru/title.txt +++ b/fastlane/metadata/android/ru/title.txt @@ -1 +1 @@ -Ntfy - PUT/POST на ваш телефон +ntfy - PUT/POST на ваш телефон diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt index 9618765..ec1e7c6 100644 --- a/fastlane/metadata/android/tr/full_description.txt +++ b/fastlane/metadata/android/tr/full_description.txt @@ -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 diff --git a/fastlane/metadata/android/tr/title.txt b/fastlane/metadata/android/tr/title.txt index a918c98..8cf67e3 100644 --- a/fastlane/metadata/android/tr/title.txt +++ b/fastlane/metadata/android/tr/title.txt @@ -1 +1 @@ -Ntfy - Telefonunuza PUT/POST +ntfy - Telefonunuza PUT/POST diff --git a/settings.gradle b/settings.gradle index 6e17991..231bc04 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ -rootProject.name='Ntfy' +rootProject.name='ntfy' include ':app'