From 2f8be72c12ee6b3848903136733e05140a635c87 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 16 Apr 2022 20:12:40 -0400 Subject: [PATCH 1/8] Actions --- app/schemas/io.heckel.ntfy.db.Database/9.json | 12 ++++++-- .../java/io/heckel/ntfy/backup/Backuper.kt | 1 + .../main/java/io/heckel/ntfy/db/Database.kt | 28 ++++++++++++++++++- .../main/java/io/heckel/ntfy/msg/Message.kt | 9 ++++++ .../io/heckel/ntfy/msg/NotificationParser.kt | 7 +++++ .../io/heckel/ntfy/msg/NotificationService.kt | 24 ++++++++++++++++ .../heckel/ntfy/firebase/FirebaseService.kt | 1 + 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.db.Database/9.json b/app/schemas/io.heckel.ntfy.db.Database/9.json index 024364b..6a470b8 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/9.json +++ b/app/schemas/io.heckel.ntfy.db.Database/9.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 9, - "identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669", + "identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb", "entities": [ { "tableName": "Subscription", @@ -82,7 +82,7 @@ }, { "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, `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`))", + "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", @@ -145,6 +145,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "deleted", "columnName": "deleted", @@ -290,7 +296,7 @@ "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, '5bab75c3b41c53c9855fe3a7ef8f0669')" + "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/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 52720d3..5614536 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -133,6 +133,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, + actions = null, // FIXME attachment = attachment, deleted = n.deleted )) 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..e7cd41c 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, ) @@ -73,6 +76,28 @@ data class Attachment( this(name, type, size, expires, url, null, PROGRESS_NONE) } +@Entity +data class Action( + @ColumnInfo(name = "action") val action: String, + @ColumnInfo(name = "label") val label: String, + @ColumnInfo(name = "url") val url: String?, +) + +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) + } +} + const val PROGRESS_NONE = -1 const val PROGRESS_INDETERMINATE = -2 const val PROGRESS_FAILED = -3 @@ -102,6 +127,7 @@ data class LogEntry( } @androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 9) +@TypeConverters(Converters::class) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao 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..8653fe6 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,11 @@ data class MessageAttachment( val url: String, ) +@Keep +data class MessageAction( + val action: String, + val label: String, + val url: String?, +) + const val MESSAGE_ENCODING_BASE64 = "base64" 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..817bc9d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.msg import android.util.Base64 import com.google.gson.Gson +import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.joinTags @@ -29,6 +30,11 @@ class NotificationParser { url = message.attachment.url, ) } else null + val actions = if (message.actions != null) { + message.actions.map { action -> + Action(action.action, action.label, action.url) + } + } else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, @@ -39,6 +45,7 @@ class NotificationParser { priority = toPriority(message.priority), tags = joinTags(message.tags), click = message.click ?: "", + actions = actions, attachment = attachment, notificationId = notificationId, deleted = false 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..046389a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -65,6 +65,7 @@ class NotificationService(val context: Context) { maybeAddBrowseAction(builder, notification) maybeAddDownloadAction(builder, notification) maybeAddCancelAction(builder, notification) + maybeAddCustomActions(builder, notification) maybeCreateNotificationChannel(notification.priority) notificationManager.notify(notification.notificationId, builder.build()) @@ -190,6 +191,29 @@ class NotificationService(val context: Context) { } } + private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { + notification.actions?.forEach { action -> + when (action.action) { + "view" -> maybeAddOpenUserAction(builder, notification, action) + } + } + } + + private fun maybeAddOpenUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { + Log.d(TAG, "Adding user action $action") + + val url = action.url ?: return + try { + val uri = Uri.parse(url) + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val pendingIntent = PendingIntent.getActivity(context, 0, 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) + } + } + class DownloadBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val id = intent.getStringExtra("id") ?: return 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..a3db0db 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -131,6 +131,7 @@ class FirebaseService : FirebaseMessagingService() { priority = toPriority(priority), tags = tags ?: "", click = click ?: "", + actions = null, // FIXME attachment = attachment, notificationId = Random.nextInt(), deleted = false From f62b7fa952fb557910b323edb0d2392ab620b398 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 16 Apr 2022 22:32:29 -0400 Subject: [PATCH 2/8] Derp --- app/src/main/AndroidManifest.xml | 2 +- .../java/io/heckel/ntfy/msg/ApiService.kt | 2 - .../io/heckel/ntfy/msg/DownloadManager.kt | 43 +++++---- .../io/heckel/ntfy/msg/NotificationService.kt | 90 +++++++++++++------ .../io/heckel/ntfy/msg/UserActionManager.kt | 37 ++++++++ .../io/heckel/ntfy/msg/UserActionWorker.kt | 68 ++++++++++++++ 6 files changed, 190 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt create mode 100644 app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt 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/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/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/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index 046389a..7d16e05 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -154,9 +154,10 @@ 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 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, 0, intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) } @@ -164,8 +165,9 @@ class NotificationService(val context: Context) { 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 intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) } @@ -173,9 +175,10 @@ class NotificationService(val context: Context) { 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 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, 0, 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()) } @@ -183,9 +186,10 @@ class NotificationService(val context: Context) { 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 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, 0, 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()) } @@ -194,19 +198,19 @@ class NotificationService(val context: Context) { private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { notification.actions?.forEach { action -> when (action.action) { - "view" -> maybeAddOpenUserAction(builder, notification, action) + "view" -> maybeAddViewUserAction(builder, action) + "http-post" -> maybeAddHttpPostUserAction(builder, notification, action) } } } - private fun maybeAddOpenUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { + private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) { Log.d(TAG, "Adding user action $action") - - val url = action.url ?: return try { - val uri = Uri.parse(url) - val intent = Intent(Intent.ACTION_VIEW, uri) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + 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, 0, intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) } catch (e: Exception) { @@ -214,13 +218,40 @@ class NotificationService(val context: Context) { } } - class DownloadBroadcastReceiver : BroadcastReceiver() { + private fun maybeAddHttpPostUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { + val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply { + putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) + putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_HTTP) + putExtra(BROADCAST_EXTRA_ACTION, action.action) + putExtra(BROADCAST_EXTRA_URL, action.url) + } + val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + builder.addAction(NotificationCompat.Action.Builder(0, action.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) + Log.d(TAG, "Received $intent") + val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return + val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return + when (type) { + BROADCAST_TYPE_DOWNLOAD_START, BROADCAST_TYPE_DOWNLOAD_CANCEL -> handleDownloadAction(context, type, notificationId) + BROADCAST_TYPE_HTTP -> handleCustomUserAction(context, intent, type, notificationId) + } + } + + private fun handleDownloadAction(context: Context, type: String, notificationId: String) { + when (type) { + BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true) + BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId) + } + } + + private fun handleCustomUserAction(context: Context, intent: Intent, type: String, notificationId: String) { + val action = intent.getStringExtra(BROADCAST_EXTRA_ACTION) ?: return + val url = intent.getStringExtra(BROADCAST_EXTRA_URL) ?: return + when (type) { + BROADCAST_TYPE_HTTP -> UserActionManager.enqueue(context, notificationId, action, url) } } } @@ -287,8 +318,15 @@ class NotificationService(val context: Context) { companion object { 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 BROADCAST_EXTRA_TYPE = "type" + private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" + private const val BROADCAST_EXTRA_ACTION = "action" + private const val BROADCAST_EXTRA_URL = "url" + + private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" + private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" + private const val BROADCAST_TYPE_HTTP = "io.heckel.ntfy.USER_ACTION_HTTP" 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..5b1e4fd --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt @@ -0,0 +1,37 @@ +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 +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * 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, action: String, url: String) { + val workManager = WorkManager.getInstance(context) + val workName = WORK_NAME_PREFIX + notificationId + action + url + Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, work: $workName") + val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java) + .setInputData(workDataOf( + UserActionWorker.INPUT_DATA_ID to notificationId, + UserActionWorker.INPUT_DATA_ACTION to action, + UserActionWorker.INPUT_DATA_URL to url, + )) + .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..d936064 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -0,0 +1,68 @@ +package io.heckel.ntfy.msg + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.work.Worker +import androidx.work.WorkerParameters +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.R +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.db.* +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.ensureSafeNewFile +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.File +import java.util.concurrent.TimeUnit + +class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { + private val client = OkHttpClient.Builder() + .callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + override fun doWork(): Result { + if (context.applicationContext !is Application) return Result.failure() + val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure() + val action = inputData.getString(INPUT_DATA_ACTION) ?: return Result.failure() + val url = inputData.getString(INPUT_DATA_URL) ?: return Result.failure() + val app = context.applicationContext as Application + + http(context, url) + + return Result.success() + } + + + fun http(context: Context, url: String) { // FIXME Worker! + Log.d(TAG, "HTTP POST againt $url") + val request = Request.Builder() + .url(url) + .addHeader("User-Agent", ApiService.USER_AGENT) + .method("POST", "".toRequestBody()) + .build() + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + return + } + throw Exception("Unexpected server response ${response.code}") + } + } + + companion object { + const val INPUT_DATA_ID = "id" + const val INPUT_DATA_ACTION = "action" + const val INPUT_DATA_URL = "url" + + private const val TAG = "NtfyUserActWrk" + } +} From 686616d4d23ef436ac36c11a6ce42933a88fa7d4 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 17 Apr 2022 14:29:29 -0400 Subject: [PATCH 3/8] WIP --- .../io.heckel.ntfy.db.Database/10.json | 302 ++++++++++++++++++ app/schemas/io.heckel.ntfy.db.Database/9.json | 12 +- .../main/java/io/heckel/ntfy/db/Database.kt | 8 +- .../main/java/io/heckel/ntfy/msg/Message.kt | 6 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 5 +- .../io/heckel/ntfy/msg/NotificationService.kt | 44 ++- .../io/heckel/ntfy/msg/UserActionManager.kt | 15 +- .../io/heckel/ntfy/msg/UserActionWorker.kt | 37 +-- 8 files changed, 357 insertions(+), 72 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.db.Database/10.json 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/schemas/io.heckel.ntfy.db.Database/9.json b/app/schemas/io.heckel.ntfy.db.Database/9.json index 6a470b8..024364b 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/9.json +++ b/app/schemas/io.heckel.ntfy.db.Database/9.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 9, - "identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb", + "identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669", "entities": [ { "tableName": "Subscription", @@ -82,7 +82,7 @@ }, { "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`))", + "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, `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", @@ -145,12 +145,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "actions", - "columnName": "actions", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "deleted", "columnName": "deleted", @@ -296,7 +290,7 @@ "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')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bab75c3b41c53c9855fe3a7ef8f0669')" ] } } \ No newline at end of file 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 e7cd41c..c5fac86 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -78,9 +78,13 @@ data class Attachment( @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, @ColumnInfo(name = "label") val label: String, - @ColumnInfo(name = "url") val url: String?, + @ColumnInfo(name = "url") val url: String?, // used in "view" and "http" + @ColumnInfo(name = "method") val method: String?, // used in "http" + @ColumnInfo(name = "headers") val headers: Map?, // used in "http" + @ColumnInfo(name = "body") val body: String?, // used in "http" ) class Converters { @@ -126,7 +130,7 @@ 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 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 8653fe6..7d66814 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -32,9 +32,13 @@ data class MessageAttachment( @Keep data class MessageAction( + val id: String, val action: String, val label: String, - val url: String?, + val url: String?, // used in "view" and "http" + val method: String?, // used in "http" + val headers: Map?, // used in "http" + val body: String?, // used in "http" ) const val MESSAGE_ENCODING_BASE64 = "base64" 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 817bc9d..dd60b78 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -6,6 +6,7 @@ import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.joinTags +import io.heckel.ntfy.util.randomString import io.heckel.ntfy.util.toPriority class NotificationParser { @@ -31,8 +32,8 @@ class NotificationParser { ) } else null val actions = if (message.actions != null) { - message.actions.map { action -> - Action(action.action, action.label, action.url) + message.actions.map { a -> + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body) } } else null 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 7d16e05..b8ab7fb 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -16,6 +16,7 @@ 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 @@ -197,9 +198,9 @@ class NotificationService(val context: Context) { private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { notification.actions?.forEach { action -> - when (action.action) { - "view" -> maybeAddViewUserAction(builder, action) - "http-post" -> maybeAddHttpPostUserAction(builder, notification, action) + when (action.action.lowercase(Locale.getDefault())) { + ACTION_VIEW -> maybeAddViewUserAction(builder, action) + ACTION_HTTP -> maybeAddHttpUserAction(builder, notification, action) } } } @@ -218,12 +219,11 @@ class NotificationService(val context: Context) { } } - private fun maybeAddHttpPostUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { + private fun maybeAddHttpUserAction(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_TYPE, BROADCAST_TYPE_HTTP) - putExtra(BROADCAST_EXTRA_ACTION, action.action) - putExtra(BROADCAST_EXTRA_URL, action.url) + putExtra(BROADCAST_EXTRA_ACTION_ID, action.id) } val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) @@ -231,27 +231,16 @@ class NotificationService(val context: Context) { class UserActionBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "Received $intent") + Log.d(TAG, "Notification user action intent received: $intent") val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return - when (type) { - BROADCAST_TYPE_DOWNLOAD_START, BROADCAST_TYPE_DOWNLOAD_CANCEL -> handleDownloadAction(context, type, notificationId) - BROADCAST_TYPE_HTTP -> handleCustomUserAction(context, intent, type, notificationId) - } - } - - private fun handleDownloadAction(context: Context, type: String, notificationId: String) { when (type) { BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true) BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId) - } - } - - private fun handleCustomUserAction(context: Context, intent: Intent, type: String, notificationId: String) { - val action = intent.getStringExtra(BROADCAST_EXTRA_ACTION) ?: return - val url = intent.getStringExtra(BROADCAST_EXTRA_URL) ?: return - when (type) { - BROADCAST_TYPE_HTTP -> UserActionManager.enqueue(context, notificationId, action, url) + BROADCAST_TYPE_USER_ACTION -> { + val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return + UserActionManager.enqueue(context, notificationId, actionId) + } } } } @@ -321,12 +310,15 @@ class NotificationService(val context: Context) { private const val BROADCAST_EXTRA_TYPE = "type" private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" - private const val BROADCAST_EXTRA_ACTION = "action" - private const val BROADCAST_EXTRA_URL = "url" + private const val BROADCAST_EXTRA_ACTION_ID = "action" + private const val BROADCAST_EXTRA_ACTION_JSON = "actionJson" private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" - private const val BROADCAST_TYPE_HTTP = "io.heckel.ntfy.USER_ACTION_HTTP" + private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION" + + private const val ACTION_VIEW = "view" + private const val ACTION_HTTP = "http" private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_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 index 5b1e4fd..374db0d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt @@ -6,10 +6,6 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.workDataOf import io.heckel.ntfy.util.Log -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit /** * Trigger user actions clicked from notification popups. @@ -21,15 +17,14 @@ object UserActionManager { private const val TAG = "NtfyUserActionEx" private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_" - fun enqueue(context: Context, notificationId: String, action: String, url: String) { + fun enqueue(context: Context, notificationId: String, actionId: String) { val workManager = WorkManager.getInstance(context) - val workName = WORK_NAME_PREFIX + notificationId + action + url - Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, work: $workName") + 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_ID to notificationId, - UserActionWorker.INPUT_DATA_ACTION to action, - UserActionWorker.INPUT_DATA_URL to url, + 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 index d936064..d53c353 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -1,25 +1,14 @@ package io.heckel.ntfy.msg import android.content.Context -import android.net.Uri -import android.os.Handler -import android.os.Looper -import android.webkit.MimeTypeMap -import android.widget.Toast -import androidx.core.content.FileProvider import androidx.work.Worker import androidx.work.WorkerParameters -import io.heckel.ntfy.BuildConfig -import io.heckel.ntfy.R import io.heckel.ntfy.app.Application -import io.heckel.ntfy.db.* +import io.heckel.ntfy.db.Action import io.heckel.ntfy.util.Log -import io.heckel.ntfy.util.ensureSafeNewFile import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.io.File import java.util.concurrent.TimeUnit class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { @@ -32,23 +21,28 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : override fun doWork(): Result { if (context.applicationContext !is Application) return Result.failure() - val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure() - val action = inputData.getString(INPUT_DATA_ACTION) ?: return Result.failure() - val url = inputData.getString(INPUT_DATA_URL) ?: 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 + val notification = app.repository.getNotification(notificationId) ?: return Result.failure() + val action = notification.actions?.first { it.id == actionId } ?: return Result.failure() - http(context, url) + Log.d(TAG, "Executing action $action for notification $notification") + http(context, action) return Result.success() } - fun http(context: Context, url: String) { // FIXME Worker! - Log.d(TAG, "HTTP POST againt $url") + fun http(context: Context, action: Action) { // FIXME Worker! + val url = action.url ?: return + val method = action.method ?: "GET" + val body = action.body ?: "" + Log.d(TAG, "HTTP POST againt ${action.url}") val request = Request.Builder() .url(url) .addHeader("User-Agent", ApiService.USER_AGENT) - .method("POST", "".toRequestBody()) + .method(method, body.toRequestBody()) .build() client.newCall(request).execute().use { response -> if (response.isSuccessful) { @@ -59,9 +53,8 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : } companion object { - const val INPUT_DATA_ID = "id" - const val INPUT_DATA_ACTION = "action" - const val INPUT_DATA_URL = "url" + const val INPUT_DATA_NOTIFICATION_ID = "notificationId" + const val INPUT_DATA_ACTION_ID = "actionId" private const val TAG = "NtfyUserActWrk" } From 79c0e91e8de3298b54a689eea097b7eeac2c28d6 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 19 Apr 2022 09:15:06 -0400 Subject: [PATCH 4/8] Actions WIP --- .../main/java/io/heckel/ntfy/db/Database.kt | 31 +++++--- .../io/heckel/ntfy/msg/BroadcastService.kt | 17 +++- .../java/io/heckel/ntfy/msg/DownloadWorker.kt | 8 +- .../main/java/io/heckel/ntfy/msg/Message.kt | 5 +- .../heckel/ntfy/msg/NotificationDispatcher.kt | 3 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 2 +- .../io/heckel/ntfy/msg/NotificationService.kt | 78 +++++++++++-------- .../io/heckel/ntfy/msg/UserActionWorker.kt | 70 +++++++++++++---- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 8 +- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 4 +- app/src/main/res/values/strings.xml | 1 + 11 files changed, 153 insertions(+), 74 deletions(-) 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 c5fac86..92d9249 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -73,20 +73,33 @@ 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 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, + @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" - @ColumnInfo(name = "method") val method: String?, // used in "http" - @ColumnInfo(name = "headers") val headers: Map?, // used in "http" - @ColumnInfo(name = "body") val body: String?, // used in "http" + @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 = "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() @@ -102,12 +115,6 @@ class Converters { } } -const val PROGRESS_NONE = -1 -const val PROGRESS_INDETERMINATE = -2 -const val PROGRESS_FAILED = -3 -const val PROGRESS_DELETED = -4 -const val PROGRESS_DONE = 100 - @Entity data class User( @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String, 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..7cf5a55 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") + ctx.sendBroadcast(intent) + } + + fun sendUserAction(action: Action) { + val intent = Intent() + intent.action = USER_ACTION_ACTION + action.extras?.forEach { (key, value) -> + intent.putExtra(key, value) + } + Log.d(TAG, "Sending user action intent broadcast: $intent") ctx.sendBroadcast(intent) } @@ -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/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 7d66814..1d86c17 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -34,11 +34,12 @@ data class MessageAttachment( data class MessageAction( val id: String, val action: String, - val label: String, + val label: String, // "view", "broadcast" or "http" val url: String?, // used in "view" and "http" - val method: String?, // used in "http" + val method: String?, // used in "http", default is POST (!) val headers: Map?, // used in "http" val body: String?, // used in "http" + val extras: Map?, // used in "broadcast" ) 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 dd60b78..4d00781 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -33,7 +33,7 @@ class NotificationParser { } else null val actions = if (message.actions != null) { message.actions.map { a -> - Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body) + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.extras, null, null) } } else null 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 b8ab7fb..db3b70d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -66,7 +66,7 @@ class NotificationService(val context: Context) { maybeAddBrowseAction(builder, notification) maybeAddDownloadAction(builder, notification) maybeAddCancelAction(builder, notification) - maybeAddCustomActions(builder, notification) + maybeAddUserActions(builder, notification) maybeCreateNotificationChannel(notification.priority) notificationManager.notify(notification.notificationId, builder.build()) @@ -90,43 +90,55 @@ 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, attachmentInfos) + } + + private fun maybeAppendActionErrors(message: String, notification: Notification): String { + val actionErrors = notification.actions + .orEmpty() + .mapNotNull { action -> action.error } + .joinToString("\n") + if (actionErrors.isEmpty()) { + return message + } else { + return "${message}\n\n${actionErrors}" } - return context.getString(R.string.notification_popup_file, message, infos) } private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { @@ -135,7 +147,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)) @@ -159,7 +171,7 @@ class NotificationService(val context: Context) { setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) } } @@ -169,18 +181,18 @@ class NotificationService(val context: Context) { val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) } } private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) { - if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { + if (notification.attachment?.contentUri == null && listOf(ATTACHMENT_PROGRESS_NONE, ATTACHMENT_PROGRESS_FAILED).contains(notification.attachment?.progress)) { val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply { putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) } - val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build()) } } @@ -191,47 +203,51 @@ class NotificationService(val context: Context) { putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id) } - val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build()) } } - private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { + 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 -> maybeAddHttpUserAction(builder, notification, action) + ACTION_HTTP, ACTION_BROADCAST -> maybeAddHttpOrBroadcastUserAction(builder, notification, action) } } } private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) { - Log.d(TAG, "Adding user action $action") 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, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) } catch (e: Exception) { Log.w(TAG, "Unable to add open user action", e) } } - private fun maybeAddHttpUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { + private fun maybeAddHttpOrBroadcastUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply { 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, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) + val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val label = when (action.progress) { + ACTION_PROGRESS_ONGOING -> action.label + " …" + ACTION_PROGRESS_SUCCESS -> action.label + " ✔️" + ACTION_PROGRESS_FAILED -> action.label + " ❌️" + else -> action.label + } + builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build()) } class UserActionBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "Notification user action intent received: $intent") val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return when (type) { @@ -306,19 +322,19 @@ class NotificationService(val context: Context) { } companion object { + val ACTION_VIEW = "view" + val ACTION_HTTP = "http" + val ACTION_BROADCAST = "broadcast" + private const val TAG = "NtfyNotifService" private const val BROADCAST_EXTRA_TYPE = "type" private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" private const val BROADCAST_EXTRA_ACTION_ID = "action" - private const val BROADCAST_EXTRA_ACTION_JSON = "actionJson" private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" - private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION" - - private const val ACTION_VIEW = "view" - private const val ACTION_HTTP = "http" + private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN" private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_LOW = "ntfy-low" diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt index d53c353..b6fba90 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -3,55 +3,99 @@ 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.Action +import io.heckel.ntfy.db.* +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP import io.heckel.ntfy.util.Log import 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(15, TimeUnit.MINUTES) // Total timeout for entire request + .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 - val notification = app.repository.getNotification(notificationId) ?: return Result.failure() - val action = notification.actions?.first { it.id == actionId } ?: return Result.failure() + + repository = app.repository + notification = repository.getNotification(notificationId) ?: return Result.failure() + subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() + action = notification.actions?.first { it.id == actionId } ?: return Result.failure() Log.d(TAG, "Executing action $action for notification $notification") - http(context, action) - + try { + when (action.action) { + ACTION_HTTP -> performHttpAction(action) + ACTION_BROADCAST -> performBroadcastAction(action) + } + } catch (e: Exception) { + Log.w(TAG, "Error executing action: ${e.message}", e) + save(action.copy( + progress = ACTION_PROGRESS_FAILED, + error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message) + )) + } return Result.success() } + private fun performHttpAction(action: Action) { + save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null)) - fun http(context: Context, action: Action) { // FIXME Worker! val url = action.url ?: return - val method = action.method ?: "GET" + val method = action.method ?: "POST" // (not GET, because POST as a default makes more sense!) val body = action.body ?: "" - Log.d(TAG, "HTTP POST againt ${action.url}") - val request = Request.Builder() + val builder = Request.Builder() .url(url) - .addHeader("User-Agent", ApiService.USER_AGENT) .method(method, body.toRequestBody()) - .build() + .addHeader("User-Agent", ApiService.USER_AGENT) + action.headers?.forEach { (key, value) -> + builder.addHeader(key, value) + } + val request = builder.build() + + Log.d(TAG, "Executing HTTP request: ${method.uppercase(Locale.getDefault())} ${action.url}") client.newCall(request).execute().use { response -> if (response.isSuccessful) { + save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) return } - throw Exception("Unexpected server response ${response.code}") + throw Exception("HTTP ${response.code}") } } + private fun performBroadcastAction(action: Action) { + broadcaster.sendUserAction(action) + save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) + } + + private fun save(newAction: Action) { + Log.d(TAG, "Updating action: $newAction") + val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a } + val newNotification = notification.copy(actions = newActions) + action = newAction + notification = newNotification + notifier.update(subscription, notification) + repository.updateNotification(notification) + } + companion object { const val INPUT_DATA_NOTIFICATION_ID = "notificationId" const val INPUT_DATA_ACTION_ID = "actionId" 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..64a361d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -217,10 +217,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 +357,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) { 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/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 From 2625513216d147d8278db684be9a62ba7aa30407 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 19 Apr 2022 19:20:39 -0400 Subject: [PATCH 5/8] Backup+restore, Firebase, formatting, custom intent action --- .../java/io/heckel/ntfy/backup/Backuper.kt | 58 ++++++++++++++++++- .../main/java/io/heckel/ntfy/db/Database.kt | 1 + .../io/heckel/ntfy/msg/BroadcastService.kt | 6 +- .../main/java/io/heckel/ntfy/msg/Message.kt | 11 ++-- .../io/heckel/ntfy/msg/NotificationParser.kt | 18 +++++- .../io/heckel/ntfy/msg/NotificationService.kt | 45 +++++--------- .../io/heckel/ntfy/msg/UserActionWorker.kt | 18 ++++-- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 38 +++++++++++- app/src/main/java/io/heckel/ntfy/util/Util.kt | 25 +++++++- app/src/main/res/values/values.xml | 2 +- .../heckel/ntfy/firebase/FirebaseService.kt | 7 ++- 11 files changed, 173 insertions(+), 56 deletions(-) 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 5614536..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,7 +153,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, - actions = null, // FIXME + actions = actions, attachment = attachment, deleted = n.deleted )) @@ -202,6 +222,25 @@ class Backuper(val context: Context) { private suspend fun createNotificationList(): List { return repository.getNotifications().map { n -> + val actions = if (n.actions != null) { + n.actions.map { a -> + Action( + id = a.id, + action = a.action, + label = a.label, + url = a.url, + method = a.method, + headers = a.headers, + body = a.body, + intent = a.intent, + extras = a.extras, + progress = a.progress, + error = a.error + ) + } + } else { + null + } val attachment = if (n.attachment != null) { Attachment( name = n.attachment.name, @@ -225,6 +264,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, + actions = actions, attachment = attachment, deleted = n.deleted ) @@ -291,10 +331,25 @@ data class Notification( val priority: Int, // 1=min, 3=default, 5=max val tags: String, val click: String, // URL/intent to open on notification click + val actions: List?, 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 @@ -305,7 +360,6 @@ data class Attachment( val progress: Int, // Progress during download, -1 if not downloaded ) - data class User( val baseUrl: String, val username: String, 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 92d9249..d5e6a87 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -91,6 +91,7 @@ data class Action( @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 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 7cf5a55..e5f46fd 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -34,17 +34,17 @@ class BroadcastService(private val ctx: Context) { intent.putExtra("muted", muted) intent.putExtra("muted_str", muted.toString()) - Log.d(TAG, "Sending message intent broadcast: $intent") + Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}") ctx.sendBroadcast(intent) } fun sendUserAction(action: Action) { val intent = Intent() - intent.action = USER_ACTION_ACTION + intent.action = action.intent ?: USER_ACTION_ACTION action.extras?.forEach { (key, value) -> intent.putExtra(key, value) } - Log.d(TAG, "Sending user action intent broadcast: $intent") + Log.d(TAG, "Sending user action intent broadcast: ${intent.action} with extras ${intent.extras}") ctx.sendBroadcast(intent) } 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 1d86c17..e2fcd76 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -35,11 +35,12 @@ data class MessageAction( val id: String, val action: String, val label: String, // "view", "broadcast" or "http" - val url: String?, // used in "view" and "http" - val method: String?, // used in "http", default is POST (!) - val headers: Map?, // used in "http" - val body: String?, // used in "http" - val extras: Map?, // used in "broadcast" + val url: String?, // used in "view" and "http" actions + val method: String?, // used in "http" action, default is POST (!) + val headers: Map?, // 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/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 4d00781..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,13 +1,13 @@ package io.heckel.ntfy.msg -import android.util.Base64 import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.joinTags -import io.heckel.ntfy.util.randomString import io.heckel.ntfy.util.toPriority +import java.lang.reflect.Type class NotificationParser { private val gson = Gson() @@ -33,7 +33,7 @@ class NotificationParser { } else null val actions = if (message.actions != null) { message.actions.map { a -> - Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.extras, null, null) + Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null) } } else null val notification = Notification( @@ -54,5 +54,17 @@ class NotificationParser { return NotificationWithTopic(message.topic, notification) } + /** + * Parse JSON array to Action list. The indirection via MessageAction is probably + * not necessary, but for "good form". + */ + fun parseActions(s: String?): List? { + 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 db3b70d..4cbb18b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -129,18 +129,6 @@ class NotificationService(val context: Context) { return context.getString(R.string.notification_popup_file, message, attachmentInfos) } - private fun maybeAppendActionErrors(message: String, notification: Notification): String { - val actionErrors = notification.actions - .orEmpty() - .mapNotNull { action -> action.error } - .joinToString("\n") - if (actionErrors.isEmpty()) { - return message - } else { - return "${message}\n\n${actionErrors}" - } - } - private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { if (notification.click == "") { builder.setContentIntent(detailActivityIntent(subscription)) @@ -218,6 +206,10 @@ class NotificationService(val context: Context) { } private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) { + // Note that this function is (almost) duplicated in DetailAdapter, since we need to be able + // to open a link from the detail activity as well. We can't do this in the UserActionWorker, + // because the behavior is kind of weird in Android. + try { val url = action.url ?: return val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { @@ -237,12 +229,7 @@ class NotificationService(val context: Context) { putExtra(BROADCAST_EXTRA_ACTION_ID, action.id) } val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val label = when (action.progress) { - ACTION_PROGRESS_ONGOING -> action.label + " …" - ACTION_PROGRESS_SUCCESS -> action.label + " ✔️" - ACTION_PROGRESS_FAILED -> action.label + " ❌️" - else -> action.label - } + val label = formatActionLabel(action) builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build()) } @@ -322,20 +309,20 @@ class NotificationService(val context: Context) { } companion object { - val ACTION_VIEW = "view" - val ACTION_HTTP = "http" - val ACTION_BROADCAST = "broadcast" + const val ACTION_VIEW = "view" + const val ACTION_HTTP = "http" + const val ACTION_BROADCAST = "broadcast" + + const val BROADCAST_EXTRA_TYPE = "type" + const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" + const val BROADCAST_EXTRA_ACTION_ID = "action" + + const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" + const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" + const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN" private const val TAG = "NtfyNotifService" - private const val BROADCAST_EXTRA_TYPE = "type" - private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId" - private const val BROADCAST_EXTRA_ACTION_ID = "action" - - private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START" - private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" - private const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN" - private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_DEFAULT = "ntfy" diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt index b6fba90..91f1aa6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -1,6 +1,8 @@ package io.heckel.ntfy.msg import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.work.Worker import androidx.work.WorkerParameters import io.heckel.ntfy.R @@ -8,6 +10,7 @@ import io.heckel.ntfy.app.Application import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP +import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.Log import okhttp3.OkHttpClient import okhttp3.Request @@ -43,8 +46,11 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : Log.d(TAG, "Executing action $action for notification $notification") try { when (action.action) { - ACTION_HTTP -> performHttpAction(action) + // ACTION_VIEW is not handled here. It has to be handled in the foreground to avoid + // weird Android behavior. + ACTION_BROADCAST -> performBroadcastAction(action) + ACTION_HTTP -> performHttpAction(action) } } catch (e: Exception) { Log.w(TAG, "Error executing action: ${e.message}", e) @@ -56,6 +62,11 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : return Result.success() } + private fun performBroadcastAction(action: Action) { + broadcaster.sendUserAction(action) + save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) + } + private fun performHttpAction(action: Action) { save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null)) @@ -81,11 +92,6 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : } } - private fun performBroadcastAction(action: Action) { - broadcaster.sendUserAction(action) - save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) - } - private fun save(newAction: Action) { Log.d(TAG, "Updating action: $newAction") val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a } 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 64a361d..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 } @@ -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/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 a3db0db..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,13 +134,13 @@ class FirebaseService : FirebaseMessagingService() { priority = toPriority(priority), tags = tags ?: "", click = click ?: "", - actions = null, // FIXME + actions = parser.parseActions(actions), attachment = attachment, notificationId = Random.nextInt(), deleted = false ) if (repository.addNotification(notification)) { - Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") + Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") dispatcher.dispatch(subscription, notification) } } From 31bdc2bc81083cb5a0db72963a9bc8ed4dbe7360 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 21 Apr 2022 19:47:18 -0400 Subject: [PATCH 6/8] Bump FB, cleanup --- app/build.gradle | 2 +- app/src/main/java/io/heckel/ntfy/db/Database.kt | 7 +++++++ .../java/io/heckel/ntfy/msg/NotificationService.kt | 11 +++++++---- .../main/java/io/heckel/ntfy/msg/UserActionWorker.kt | 4 ---- 4 files changed, 15 insertions(+), 9 deletions(-) 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/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index d5e6a87..9f7cac1 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -162,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 @@ -237,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 NOT NULL DEFAULT('')") + } + } } } 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 4cbb18b..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,8 +15,6 @@ 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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt index 91f1aa6..d505813 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt @@ -1,8 +1,6 @@ package io.heckel.ntfy.msg import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.work.Worker import androidx.work.WorkerParameters import io.heckel.ntfy.R @@ -10,7 +8,6 @@ import io.heckel.ntfy.app.Application import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP -import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.Log import okhttp3.OkHttpClient import okhttp3.Request @@ -64,7 +61,6 @@ class UserActionWorker(private val context: Context, params: WorkerParameters) : private fun performBroadcastAction(action: Action) { broadcaster.sendUserAction(action) - save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null)) } private fun performHttpAction(action: Action) { From 4ff9a37052aec1eaf4365127ed2ad1b5d17d8ac4 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 21 Apr 2022 19:55:13 -0400 Subject: [PATCH 7/8] Fix migration --- app/src/main/java/io/heckel/ntfy/db/Database.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9f7cac1..2c6545b 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -241,7 +241,7 @@ abstract class Database : RoomDatabase() { private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT NOT NULL DEFAULT('')") + db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT") } } } From 5d0f6f7c095c626246eafe5c7e9dcf16bfdcd553 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 22 Apr 2022 15:01:45 -0400 Subject: [PATCH 8/8] Ntfy -> ntfy --- app/src/main/res/values-bg/strings.xml | 2 +- app/src/main/res/values-nb-rNO/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 4 ++-- fastlane/metadata/android/bg/full_description.txt | 2 +- fastlane/metadata/android/bg/title.txt | 2 +- fastlane/metadata/android/en-US/full_description.txt | 2 +- fastlane/metadata/android/en-US/title.txt | 2 +- fastlane/metadata/android/es/full_description.txt | 2 +- fastlane/metadata/android/es/title.txt | 2 +- fastlane/metadata/android/ja/full_description.txt | 2 +- fastlane/metadata/android/ja/title.txt | 2 +- fastlane/metadata/android/nb-NO/full_description.txt | 2 +- fastlane/metadata/android/nb-NO/title.txt | 2 +- fastlane/metadata/android/ru/full_description.txt | 2 +- fastlane/metadata/android/ru/title.txt | 2 +- fastlane/metadata/android/tr/full_description.txt | 2 +- fastlane/metadata/android/tr/title.txt | 2 +- settings.gradle | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) 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/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'