diff --git a/app/build.gradle b/app/build.gradle
index 7230575..60245ba 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -81,7 +81,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// Firebase, sigh ... (only Google Play)
- playImplementation 'com.google.firebase:firebase-messaging:23.0.2'
+ playImplementation 'com.google.firebase:firebase-messaging:23.0.3'
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.3.0-alpha02"
diff --git a/app/schemas/io.heckel.ntfy.db.Database/10.json b/app/schemas/io.heckel.ntfy.db.Database/10.json
new file mode 100644
index 0000000..3fd983f
--- /dev/null
+++ b/app/schemas/io.heckel.ntfy.db.Database/10.json
@@ -0,0 +1,302 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 10,
+ "identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb",
+ "entities": [
+ {
+ "tableName": "Subscription",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "topic",
+ "columnName": "topic",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instant",
+ "columnName": "instant",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutedUntil",
+ "columnName": "mutedUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "upAppId",
+ "columnName": "upAppId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "upConnectorToken",
+ "columnName": "upConnectorToken",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_Subscription_baseUrl_topic",
+ "unique": true,
+ "columnNames": [
+ "baseUrl",
+ "topic"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
+ },
+ {
+ "name": "index_Subscription_upConnectorToken",
+ "unique": true,
+ "columnNames": [
+ "upConnectorToken"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Notification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encoding",
+ "columnName": "encoding",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notificationId",
+ "columnName": "notificationId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "priority",
+ "columnName": "priority",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "3"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "click",
+ "columnName": "click",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actions",
+ "columnName": "actions",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "deleted",
+ "columnName": "deleted",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attachment.name",
+ "columnName": "attachment_name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.type",
+ "columnName": "attachment_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.size",
+ "columnName": "attachment_size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.expires",
+ "columnName": "attachment_expires",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.url",
+ "columnName": "attachment_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.contentUri",
+ "columnName": "attachment_contentUri",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "attachment.progress",
+ "columnName": "attachment_progress",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id",
+ "subscriptionId"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "User",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
+ "fields": [
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "baseUrl"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "level",
+ "columnName": "level",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exception",
+ "columnName": "exception",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c1b4f54d1d3111dc5c8f02e8fa960ceb')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9fdb969..e5be012 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -123,7 +123,7 @@
diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
index 52720d3..5c5ea2c 100644
--- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
+++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
@@ -2,6 +2,7 @@ package io.heckel.ntfy.backup
import android.content.Context
import android.net.Uri
+import androidx.room.ColumnInfo
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.stream.JsonReader
@@ -109,6 +110,25 @@ class Backuper(val context: Context) {
}
notifications.forEach { n ->
try {
+ val actions = if (n.actions != null) {
+ n.actions.map { a ->
+ io.heckel.ntfy.db.Action(
+ id = a.id,
+ action = a.action,
+ label = a.label,
+ url = a.url,
+ method = a.method,
+ headers = a.headers,
+ body = a.body,
+ intent = a.intent,
+ extras = a.extras,
+ progress = a.progress,
+ error = a.error
+ )
+ }
+ } else {
+ null
+ }
val attachment = if (n.attachment != null) {
io.heckel.ntfy.db.Attachment(
name = n.attachment.name,
@@ -133,6 +153,7 @@ class Backuper(val context: Context) {
priority = n.priority,
tags = n.tags,
click = n.click,
+ actions = actions,
attachment = attachment,
deleted = n.deleted
))
@@ -201,6 +222,25 @@ class Backuper(val context: Context) {
private suspend fun createNotificationList(): List {
return repository.getNotifications().map { n ->
+ val actions = if (n.actions != null) {
+ n.actions.map { a ->
+ Action(
+ id = a.id,
+ action = a.action,
+ label = a.label,
+ url = a.url,
+ method = a.method,
+ headers = a.headers,
+ body = a.body,
+ intent = a.intent,
+ extras = a.extras,
+ progress = a.progress,
+ error = a.error
+ )
+ }
+ } else {
+ null
+ }
val attachment = if (n.attachment != null) {
Attachment(
name = n.attachment.name,
@@ -224,6 +264,7 @@ class Backuper(val context: Context) {
priority = n.priority,
tags = n.tags,
click = n.click,
+ actions = actions,
attachment = attachment,
deleted = n.deleted
)
@@ -290,10 +331,25 @@ data class Notification(
val priority: Int, // 1=min, 3=default, 5=max
val tags: String,
val click: String, // URL/intent to open on notification click
+ val actions: List?,
val attachment: Attachment?,
val deleted: Boolean
)
+data class Action(
+ val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
+ val action: String, // "view", "http" or "broadcast"
+ val label: String,
+ val url: String?, // used in "view" and "http" actions
+ val method: String?, // used in "http" action
+ val headers: Map?, // used in "http" action
+ val body: String?, // used in "http" action
+ val intent: String?, // used in "broadcast" action
+ val extras: Map?, // used in "broadcast" action
+ val progress: Int?, // used to indicate progress in popup
+ val error: String? // used to indicate errors in popup
+)
+
data class Attachment(
val name: String, // Filename
val type: String?, // MIME type
@@ -304,7 +360,6 @@ data class Attachment(
val progress: Int, // Progress during download, -1 if not downloaded
)
-
data class User(
val baseUrl: String,
val username: String,
diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt
index ed3d550..2c6545b 100644
--- a/app/src/main/java/io/heckel/ntfy/db/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt
@@ -4,8 +4,10 @@ import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
-import io.heckel.ntfy.util.shortUrl
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
+import java.lang.reflect.Type
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
data class Subscription(
@@ -55,6 +57,7 @@ data class Notification(
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String,
@ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
+ @ColumnInfo(name = "actions") val actions: List?,
@Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@@ -70,14 +73,48 @@ data class Attachment(
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
) {
constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
- this(name, type, size, expires, url, null, PROGRESS_NONE)
+ this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE)
}
-const val PROGRESS_NONE = -1
-const val PROGRESS_INDETERMINATE = -2
-const val PROGRESS_FAILED = -3
-const val PROGRESS_DELETED = -4
-const val PROGRESS_DONE = 100
+const val ATTACHMENT_PROGRESS_NONE = -1
+const val ATTACHMENT_PROGRESS_INDETERMINATE = -2
+const val ATTACHMENT_PROGRESS_FAILED = -3
+const val ATTACHMENT_PROGRESS_DELETED = -4
+const val ATTACHMENT_PROGRESS_DONE = 100
+
+@Entity
+data class Action(
+ @ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
+ @ColumnInfo(name = "action") val action: String, // "view", "http" or "broadcast"
+ @ColumnInfo(name = "label") val label: String,
+ @ColumnInfo(name = "url") val url: String?, // used in "view" and "http" actions
+ @ColumnInfo(name = "method") val method: String?, // used in "http" action
+ @ColumnInfo(name = "headers") val headers: Map?, // used in "http" action
+ @ColumnInfo(name = "body") val body: String?, // used in "http" action
+ @ColumnInfo(name = "intent") val intent: String?, // used in "broadcast" action
+ @ColumnInfo(name = "extras") val extras: Map?, // used in "broadcast" action
+ @ColumnInfo(name = "progress") val progress: Int?, // used to indicate progress in popup
+ @ColumnInfo(name = "error") val error: String?, // used to indicate errors in popup
+)
+
+const val ACTION_PROGRESS_ONGOING = 1
+const val ACTION_PROGRESS_SUCCESS = 2
+const val ACTION_PROGRESS_FAILED = 3
+
+class Converters {
+ private val gson = Gson()
+
+ @TypeConverter
+ fun toActionList(value: String?): List? {
+ val listType: Type = object : TypeToken?>() {}.type
+ return gson.fromJson(value, listType)
+ }
+
+ @TypeConverter
+ fun fromActionList(list: List?): String {
+ return gson.toJson(list)
+ }
+}
@Entity
data class User(
@@ -101,7 +138,8 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception)
}
-@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 9)
+@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 10)
+@TypeConverters(Converters::class)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@@ -124,6 +162,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
+ .addMigrations(MIGRATION_9_10)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@@ -199,6 +238,12 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
}
}
+
+ private val MIGRATION_9_10 = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT")
+ }
+ }
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt
index d3f4eca..0693834 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt
@@ -1,6 +1,5 @@
package io.heckel.ntfy.msg
-import android.net.Uri
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification
@@ -9,7 +8,6 @@ import io.heckel.ntfy.util.*
import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
-import java.net.URL
import java.net.URLEncoder
import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit
diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
index d39bb6c..e5f46fd 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
@@ -2,8 +2,8 @@ package io.heckel.ntfy.msg
import android.content.Context
import android.content.Intent
-import android.util.Base64
import io.heckel.ntfy.R
+import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
* in order to facilitate tasks app integrations.
*/
class BroadcastService(private val ctx: Context) {
- fun send(subscription: Subscription, notification: Notification, muted: Boolean) {
+ fun sendMessage(subscription: Subscription, notification: Notification, muted: Boolean) {
val intent = Intent()
intent.action = MESSAGE_RECEIVED_ACTION
intent.putExtra("id", notification.id)
@@ -34,7 +34,17 @@ class BroadcastService(private val ctx: Context) {
intent.putExtra("muted", muted)
intent.putExtra("muted_str", muted.toString())
- Log.d(TAG, "Sending intent broadcast: $intent")
+ Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}")
+ ctx.sendBroadcast(intent)
+ }
+
+ fun sendUserAction(action: Action) {
+ val intent = Intent()
+ intent.action = action.intent ?: USER_ACTION_ACTION
+ action.extras?.forEach { (key, value) ->
+ intent.putExtra(key, value)
+ }
+ Log.d(TAG, "Sending user action intent broadcast: ${intent.action} with extras ${intent.extras}")
ctx.sendBroadcast(intent)
}
@@ -109,5 +119,6 @@ class BroadcastService(private val ctx: Context) {
// These constants cannot be changed without breaking the contract; also see manifest
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
+ private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt
index 4ff45e2..4a6739c 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt
@@ -13,30 +13,27 @@ import io.heckel.ntfy.util.Log
* The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice apparently.
*/
-class DownloadManager {
- companion object {
- private const val TAG = "NtfyDownloadManager"
- private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
+object DownloadManager {
+ private const val TAG = "NtfyDownloadManager"
+ private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
- fun enqueue(context: Context, notificationId: String, userAction: Boolean) {
- val workManager = WorkManager.getInstance(context)
- val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId
- Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
- val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
- .setInputData(workDataOf(
- DownloadWorker.INPUT_DATA_ID to notificationId,
- DownloadWorker.INPUT_DATA_USER_ACTION to userAction
- ))
- .build()
- workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
- }
-
- fun cancel(context: Context, id: String) {
- val workManager = WorkManager.getInstance(context)
- val workName = DOWNLOAD_WORK_NAME_PREFIX + id
- Log.d(TAG, "Cancelling download for notification $id, work: $workName")
- workManager.cancelUniqueWork(workName)
- }
+ fun enqueue(context: Context, notificationId: String, userAction: Boolean) {
+ val workManager = WorkManager.getInstance(context)
+ val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId
+ Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
+ val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
+ .setInputData(workDataOf(
+ DownloadWorker.INPUT_DATA_ID to notificationId,
+ DownloadWorker.INPUT_DATA_USER_ACTION to userAction
+ ))
+ .build()
+ workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
+ }
+ fun cancel(context: Context, id: String) {
+ val workManager = WorkManager.getInstance(context)
+ val workName = DOWNLOAD_WORK_NAME_PREFIX + id
+ Log.d(TAG, "Cancelling download for notification $id, work: $workName")
+ workManager.cancelUniqueWork(workName)
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt
index a8eed41..df97a81 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt
@@ -91,13 +91,13 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
if (isStopped) { // Canceled by user
- save(attachment.copy(progress = PROGRESS_NONE))
+ save(attachment.copy(progress = ATTACHMENT_PROGRESS_NONE))
return // File will be deleted in onStopped()
}
val progress = if (attachment.size != null && attachment.size!! > 0) {
(bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
} else {
- PROGRESS_INDETERMINATE
+ ATTACHMENT_PROGRESS_INDETERMINATE
}
save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis()
@@ -114,7 +114,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
save(attachment.copy(
size = bytesCopied,
contentUri = uri.toString(),
- progress = PROGRESS_DONE
+ progress = ATTACHMENT_PROGRESS_DONE
))
}
} catch (e: Exception) {
@@ -155,7 +155,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
private fun failed(e: Exception) {
Log.w(TAG, "Attachment download failed", e)
- save(attachment.copy(progress = PROGRESS_FAILED))
+ save(attachment.copy(progress = ATTACHMENT_PROGRESS_FAILED))
maybeDeleteFile()
}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt
index 51ed150..e2fcd76 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt
@@ -1,6 +1,7 @@
package io.heckel.ntfy.msg
import androidx.annotation.Keep
+import io.heckel.ntfy.db.Action
/* This annotation ensures that proguard still works in production builds,
* see https://stackoverflow.com/a/62753300/1440785 */
@@ -13,6 +14,7 @@ data class Message(
val priority: Int?,
val tags: List?,
val click: String?,
+ val actions: List?,
val title: String?,
val message: String,
val encoding: String?,
@@ -28,4 +30,17 @@ data class MessageAttachment(
val url: String,
)
+@Keep
+data class MessageAction(
+ val id: String,
+ val action: String,
+ val label: String, // "view", "broadcast" or "http"
+ val url: String?, // used in "view" and "http" actions
+ val method: String?, // used in "http" action, default is POST (!)
+ val headers: Map?, // used in "http" action
+ val body: String?, // used in "http" action
+ val intent: String?, // used in "broadcast" action
+ val extras: Map?, // used in "broadcast" action
+)
+
const val MESSAGE_ENCODING_BASE64 = "base64"
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index bfc4f86..e1afdf6 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -1,7 +1,6 @@
package io.heckel.ntfy.msg
import android.content.Context
-import android.util.Base64
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@@ -35,7 +34,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
notifier.display(subscription, notification)
}
if (broadcast) {
- broadcaster.send(subscription, notification, muted)
+ broadcaster.sendMessage(subscription, notification, muted)
}
if (distribute) {
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt
index 48b873b..b855d24 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt
@@ -1,11 +1,13 @@
package io.heckel.ntfy.msg
-import android.util.Base64
import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.joinTags
import io.heckel.ntfy.util.toPriority
+import java.lang.reflect.Type
class NotificationParser {
private val gson = Gson()
@@ -29,6 +31,11 @@ class NotificationParser {
url = message.attachment.url,
)
} else null
+ val actions = if (message.actions != null) {
+ message.actions.map { a ->
+ Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null)
+ }
+ } else null
val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
@@ -39,6 +46,7 @@ class NotificationParser {
priority = toPriority(message.priority),
tags = joinTags(message.tags),
click = message.click ?: "",
+ actions = actions,
attachment = attachment,
notificationId = notificationId,
deleted = false
@@ -46,5 +54,17 @@ class NotificationParser {
return NotificationWithTopic(message.topic, notification)
}
+ /**
+ * Parse JSON array to Action list. The indirection via MessageAction is probably
+ * not necessary, but for "good form".
+ */
+ fun parseActions(s: String?): List? {
+ val listType: Type = object : TypeToken?>() {}.type
+ val messageActions: List? = gson.fromJson(s, listType)
+ return messageActions?.map { a ->
+ Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null)
+ }
+ }
+
data class NotificationWithTopic(val topic: String, val notification: Notification)
}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
index ceafd4c..f612993 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
@@ -1,7 +1,12 @@
package io.heckel.ntfy.msg
-import android.app.*
-import android.content.*
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.TaskStackBuilder
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.net.Uri
@@ -10,12 +15,11 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.db.*
-import io.heckel.ntfy.db.Notification
-import io.heckel.ntfy.util.Log
import io.heckel.ntfy.ui.Colors
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.*
+import java.util.*
class NotificationService(val context: Context) {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -65,6 +69,7 @@ class NotificationService(val context: Context) {
maybeAddBrowseAction(builder, notification)
maybeAddDownloadAction(builder, notification)
maybeAddCancelAction(builder, notification)
+ maybeAddUserActions(builder, notification)
maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build())
@@ -88,43 +93,43 @@ class NotificationService(val context: Context) {
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder
- .setContentText(formatMessage(notification))
+ .setContentText(maybeAppendActionErrors(formatMessage(notification), notification))
.setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null))
} catch (_: Exception) {
- val message = formatMessageMaybeWithAttachmentInfo(notification)
+ val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
} else {
- val message = formatMessageMaybeWithAttachmentInfo(notification)
+ val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
}
- private fun formatMessageMaybeWithAttachmentInfo(notification: Notification): String {
+ private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String {
val message = formatMessage(notification)
val attachment = notification.attachment ?: return message
- val infos = if (attachment.size != null) {
+ val attachmentInfos = if (attachment.size != null) {
"${attachment.name}, ${formatBytes(attachment.size)}"
} else {
attachment.name
}
if (attachment.progress in 0..99) {
- return context.getString(R.string.notification_popup_file_downloading, infos, attachment.progress, message)
+ return context.getString(R.string.notification_popup_file_downloading, attachmentInfos, attachment.progress, message)
}
- if (attachment.progress == PROGRESS_DONE) {
- return context.getString(R.string.notification_popup_file_download_successful, message, infos)
+ if (attachment.progress == ATTACHMENT_PROGRESS_DONE) {
+ return context.getString(R.string.notification_popup_file_download_successful, message, attachmentInfos)
}
- if (attachment.progress == PROGRESS_FAILED) {
- return context.getString(R.string.notification_popup_file_download_failed, message, infos)
+ if (attachment.progress == ATTACHMENT_PROGRESS_FAILED) {
+ return context.getString(R.string.notification_popup_file_download_failed, message, attachmentInfos)
}
- return context.getString(R.string.notification_popup_file, message, infos)
+ return context.getString(R.string.notification_popup_file, message, attachmentInfos)
}
private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) {
@@ -133,7 +138,7 @@ class NotificationService(val context: Context) {
} else {
try {
val uri = Uri.parse(notification.click)
- val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE)
+ val viewIntent = PendingIntent.getActivity(context, Random().nextInt(), Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE)
builder.setContentIntent(viewIntent)
} catch (e: Exception) {
builder.setContentIntent(detailActivityIntent(subscription))
@@ -153,50 +158,95 @@ class NotificationService(val context: Context) {
private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri)
- val intent = Intent(Intent.ACTION_VIEW, contentUri)
- intent.setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ val intent = Intent(Intent.ACTION_VIEW, contentUri).apply {
+ setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build())
}
}
private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) {
- val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS)
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
}
}
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
- if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) {
- val intent = Intent(context, DownloadBroadcastReceiver::class.java)
- intent.putExtra("action", DOWNLOAD_ACTION_START)
- intent.putExtra("id", notification.id)
- val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ if (notification.attachment?.contentUri == null && listOf(ATTACHMENT_PROGRESS_NONE, ATTACHMENT_PROGRESS_FAILED).contains(notification.attachment?.progress)) {
+ val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
+ putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START)
+ putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
+ }
+ val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build())
}
}
private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) {
- val intent = Intent(context, DownloadBroadcastReceiver::class.java)
- intent.putExtra("action", DOWNLOAD_ACTION_CANCEL)
- intent.putExtra("id", notification.id)
- val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
+ putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL)
+ putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
+ }
+ val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build())
}
}
- class DownloadBroadcastReceiver : BroadcastReceiver() {
+ private fun maybeAddUserActions(builder: NotificationCompat.Builder, notification: Notification) {
+ notification.actions?.forEach { action ->
+ when (action.action.lowercase(Locale.getDefault())) {
+ ACTION_VIEW -> maybeAddViewUserAction(builder, action)
+ ACTION_HTTP, ACTION_BROADCAST -> maybeAddHttpOrBroadcastUserAction(builder, notification, action)
+ }
+ }
+ }
+
+ private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) {
+ // Note that this function is (almost) duplicated in DetailAdapter, since we need to be able
+ // to open a link from the detail activity as well. We can't do this in the UserActionWorker,
+ // because the behavior is kind of weird in Android.
+
+ try {
+ val url = action.url ?: return
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
+ builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to add open user action", e)
+ }
+ }
+
+ private fun maybeAddHttpOrBroadcastUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
+ val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
+ putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_USER_ACTION)
+ putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
+ putExtra(BROADCAST_EXTRA_ACTION_ID, action.id)
+ }
+ val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val label = formatActionLabel(action)
+ builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build())
+ }
+
+ class UserActionBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- val id = intent.getStringExtra("id") ?: return
- val action = intent.getStringExtra("action") ?: return
- when (action) {
- DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id, userAction = true)
- DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id)
+ val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
+ val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
+ when (type) {
+ BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true)
+ BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId)
+ BROADCAST_TYPE_USER_ACTION -> {
+ val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return
+ UserActionManager.enqueue(context, notificationId, actionId)
+ }
}
}
}
@@ -262,9 +312,19 @@ class NotificationService(val context: Context) {
}
companion object {
+ const val ACTION_VIEW = "view"
+ const val ACTION_HTTP = "http"
+ const val ACTION_BROADCAST = "broadcast"
+
+ const val BROADCAST_EXTRA_TYPE = "type"
+ const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId"
+ const val BROADCAST_EXTRA_ACTION_ID = "action"
+
+ const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
+ const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
+ const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN"
+
private const val TAG = "NtfyNotifService"
- private const val DOWNLOAD_ACTION_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
- private const val DOWNLOAD_ACTION_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low"
diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt
new file mode 100644
index 0000000..374db0d
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt
@@ -0,0 +1,32 @@
+package io.heckel.ntfy.msg
+
+import android.content.Context
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import io.heckel.ntfy.util.Log
+
+/**
+ * Trigger user actions clicked from notification popups.
+ *
+ * The indirection via WorkManager is required since this code may be executed
+ * in a doze state and Internet may not be available. It's also best practice, apparently.
+ */
+object UserActionManager {
+ private const val TAG = "NtfyUserActionEx"
+ private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_"
+
+ fun enqueue(context: Context, notificationId: String, actionId: String) {
+ val workManager = WorkManager.getInstance(context)
+ val workName = WORK_NAME_PREFIX + notificationId + "_" + actionId
+ Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, action $actionId, work: $workName")
+ val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java)
+ .setInputData(workDataOf(
+ UserActionWorker.INPUT_DATA_NOTIFICATION_ID to notificationId,
+ UserActionWorker.INPUT_DATA_ACTION_ID to actionId,
+ ))
+ .build()
+ workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt
new file mode 100644
index 0000000..d505813
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt
@@ -0,0 +1,107 @@
+package io.heckel.ntfy.msg
+
+import android.content.Context
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import io.heckel.ntfy.R
+import io.heckel.ntfy.app.Application
+import io.heckel.ntfy.db.*
+import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST
+import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP
+import io.heckel.ntfy.util.Log
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.util.*
+import java.util.concurrent.TimeUnit
+
+class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
+ private val client = OkHttpClient.Builder()
+ .callTimeout(60, TimeUnit.SECONDS) // Total timeout for entire request
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .build()
+ private val notifier = NotificationService(context)
+ private val broadcaster = BroadcastService(context)
+ private lateinit var repository: Repository
+ private lateinit var subscription: Subscription
+ private lateinit var notification: Notification
+ private lateinit var action: Action
+
+ override fun doWork(): Result {
+ if (context.applicationContext !is Application) return Result.failure()
+ val notificationId = inputData.getString(INPUT_DATA_NOTIFICATION_ID) ?: return Result.failure()
+ val actionId = inputData.getString(INPUT_DATA_ACTION_ID) ?: return Result.failure()
+ val app = context.applicationContext as Application
+
+ repository = app.repository
+ notification = repository.getNotification(notificationId) ?: return Result.failure()
+ subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
+ action = notification.actions?.first { it.id == actionId } ?: return Result.failure()
+
+ Log.d(TAG, "Executing action $action for notification $notification")
+ try {
+ when (action.action) {
+ // ACTION_VIEW is not handled here. It has to be handled in the foreground to avoid
+ // weird Android behavior.
+
+ ACTION_BROADCAST -> performBroadcastAction(action)
+ ACTION_HTTP -> performHttpAction(action)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error executing action: ${e.message}", e)
+ save(action.copy(
+ progress = ACTION_PROGRESS_FAILED,
+ error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message)
+ ))
+ }
+ return Result.success()
+ }
+
+ private fun performBroadcastAction(action: Action) {
+ broadcaster.sendUserAction(action)
+ }
+
+ private fun performHttpAction(action: Action) {
+ save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null))
+
+ val url = action.url ?: return
+ val method = action.method ?: "POST" // (not GET, because POST as a default makes more sense!)
+ val body = action.body ?: ""
+ val builder = Request.Builder()
+ .url(url)
+ .method(method, body.toRequestBody())
+ .addHeader("User-Agent", ApiService.USER_AGENT)
+ action.headers?.forEach { (key, value) ->
+ builder.addHeader(key, value)
+ }
+ val request = builder.build()
+
+ Log.d(TAG, "Executing HTTP request: ${method.uppercase(Locale.getDefault())} ${action.url}")
+ client.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null))
+ return
+ }
+ throw Exception("HTTP ${response.code}")
+ }
+ }
+
+ private fun save(newAction: Action) {
+ Log.d(TAG, "Updating action: $newAction")
+ val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a }
+ val newNotification = notification.copy(actions = newActions)
+ action = newAction
+ notification = newNotification
+ notifier.update(subscription, notification)
+ repository.updateNotification(notification)
+ }
+
+ companion object {
+ const val INPUT_DATA_NOTIFICATION_ID = "notificationId"
+ const val INPUT_DATA_ACTION_ID = "actionId"
+
+ private const val TAG = "NtfyUserActWrk"
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
index 4ff5e63..bb64ff1 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
@@ -25,6 +25,8 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadWorker
+import io.heckel.ntfy.msg.NotificationService
+import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -81,7 +83,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
dateView.text = formatDateShort(notification.timestamp)
- messageView.text = formatMessage(notification)
+ messageView.text = maybeAppendActionErrors(formatMessage(notification), notification)
newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
itemView.setOnClickListener { onClick(notification) }
itemView.setOnLongClickListener { onLongClick(notification); true }
@@ -179,6 +181,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
val attachment = notification.attachment // May be null
val hasAttachment = attachment != null
val hasClickLink = notification.click != ""
+ val hasUserActions = notification.actions?.isNotEmpty() ?: false
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel)
val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
@@ -199,6 +202,12 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
if (hasClickLink) {
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
}
+ if (notification.actions != null && notification.actions.isNotEmpty()) {
+ notification.actions.forEach { action ->
+ val actionItem = popup.menu.add(formatActionLabel(action))
+ actionItem.setOnMenuItemClickListener { runAction(context, notification, action) }
+ }
+ }
openItem.isVisible = hasAttachment && exists
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress
deleteItem.isVisible = hasAttachment && exists
@@ -208,7 +217,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
copyContentsItem.isVisible = notification.click != ""
val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
- && !copyContentsItem.isVisible
+ && !copyContentsItem.isVisible && !hasUserActions
if (noOptions) {
return null
}
@@ -217,10 +226,10 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String {
val name = attachment.name
- val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
+ val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99
- val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED)
- val failed = !exists && attachment.progress == PROGRESS_FAILED
+ val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED)
+ val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000
val infos = mutableListOf()
@@ -357,7 +366,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
if (!deleted) throw Exception("no rows deleted")
val newAttachment = attachment.copy(
contentUri = null,
- progress = PROGRESS_DELETED
+ progress = ATTACHMENT_PROGRESS_DELETED
)
val newNotification = notification.copy(attachment = newAttachment)
GlobalScope.launch(Dispatchers.IO) {
@@ -401,6 +410,31 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
copyToClipboard(context, notification)
return true
}
+
+ private fun runAction(context: Context, notification: Notification, action: Action): Boolean {
+ when (action.action) {
+ ACTION_VIEW -> runViewAction(context, action)
+ else -> runOtherUserAction(context, notification, action)
+ }
+ return true
+ }
+
+ private fun runViewAction(context: Context, action: Action) {
+ val url = action.url ?: return
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(intent)
+ }
+
+ private fun runOtherUserAction(context: Context, notification: Notification, action: Action) {
+ val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply {
+ putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION)
+ putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
+ putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id)
+ }
+ context.sendBroadcast(intent)
+ }
}
object TopicDiffCallback : DiffUtil.ItemCallback() {
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index f915215..cea4c86 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -23,9 +23,7 @@ import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import io.heckel.ntfy.R
-import io.heckel.ntfy.db.Notification
-import io.heckel.ntfy.db.Repository
-import io.heckel.ntfy.db.Subscription
+import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -185,6 +183,27 @@ fun formatTitle(notification: Notification): String {
}
}
+fun formatActionLabel(action: Action): String {
+ return when (action.progress) {
+ ACTION_PROGRESS_ONGOING -> action.label + " …"
+ ACTION_PROGRESS_SUCCESS -> action.label + " ✔️"
+ ACTION_PROGRESS_FAILED -> action.label + " ❌️"
+ else -> action.label
+ }
+}
+
+fun maybeAppendActionErrors(message: String, notification: Notification): String {
+ val actionErrors = notification.actions
+ .orEmpty()
+ .mapNotNull { action -> action.error }
+ .joinToString("\n")
+ if (actionErrors.isEmpty()) {
+ return message
+ } else {
+ return "${message}\n\n${actionErrors}"
+ }
+}
+
// Checks in the most horrible way if a content URI exists; I couldn't find a better way
fun fileExists(context: Context, contentUri: String?): Boolean {
return try {
diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt
index ee3772b..bd2c642 100644
--- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt
+++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt
@@ -5,7 +5,7 @@ import android.net.Uri
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
-import io.heckel.ntfy.db.PROGRESS_DELETED
+import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.ui.DetailAdapter
import io.heckel.ntfy.util.Log
@@ -48,7 +48,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
}
val newAttachment = attachment.copy(
contentUri = null,
- progress = PROGRESS_DELETED
+ progress = ATTACHMENT_PROGRESS_DELETED
)
val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification)
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index b2df40a..247767b 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -186,7 +186,7 @@
Копиране в междинната памет (цензурирано)
От юни 2022 г. за връзка със сървърите на ntfy ще се използва WebSockets. Не забравяйте да настроите собствения сървър да го поддържа. За да проверите дали поддръжката на WebSocket работи, разрешете я в Настройки, в раздел Протокол за връзка.
За свързване със сървъра се използва поток от JSON през HTTP. Методът е остарял и ще бъде премахнат през месец юни 2022 год.
- Това е пробно известие от приложението Ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.
+ Това е пробно известие от приложението ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.
Проба: Ако желаете можете да сложите заглавие
Грешка при изпращане: Потребителят „%1$s“ няма достъп.
Показват се известията с приоритет %1$d (%2$s) или по-висок
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index 81415a5..c38aa24 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -205,7 +205,7 @@
JSON-strøm over HTTP
Vev-sockets
Du kan legge til en bruker her som du kan tilknytte et gitt emne senere.
- Ntfy %1$s (%2$s)
+ ntfy %1$s (%2$s)
Passord (uendret hvis tomt)
Legg til bruker
Avbryt
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 4ed177d..0e7ca2c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -47,7 +47,7 @@
登录失败。用户 %1$s 无权访问。
新用户
目前还没有关于此主题的通知。
- 请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 Ntfy 服务器的推送。
+ 请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 ntfy 服务器的推送。
稍后再问
暂时不管
详情
@@ -56,7 +56,7 @@
主题名称,比如:phils_alerts
详细的说明请见 ntfy.sh 和帮助文档。
您确认要删除这个主题下的所有通知吗?
- 这是 Ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。
+ 这是 ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。
无法发送消息:用户 %1$s 无权发布。
下载文件
保存文件
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6562e40..6c01467 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -220,6 +220,7 @@
Downloading %1$s, %2$d%%\n%3$s
%1$s\nFile: %2$s, downloaded
%1$s\nFile: %2$s, download failed
+ "%1$s" failed: %2$s
Settings
diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml
index 9e6d883..4d44d2a 100644
--- a/app/src/main/res/values/values.xml
+++ b/app/src/main/res/values/values.xml
@@ -4,7 +4,7 @@
The translatable="false" attribute is just an additional safety. -->
- Ntfy
+ ntfy
https://ntfy.sh
diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
index 2506cee..ec1e130 100644
--- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
+++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
@@ -13,6 +13,7 @@ import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
import io.heckel.ntfy.msg.NotificationDispatcher
+import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl
@@ -27,6 +28,7 @@ class FirebaseService : FirebaseMessagingService() {
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val job = SupervisorJob()
private val messenger = FirebaseMessenger()
+ private val parser = NotificationParser()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Init log (this is done in all entrypoints)
@@ -88,6 +90,7 @@ class FirebaseService : FirebaseMessagingService() {
val priority = data["priority"]?.toIntOrNull()
val tags = data["tags"]
val click = data["click"]
+ val actions = data["actions"] // JSON array as string, sigh ...
val encoding = data["encoding"]
val attachmentName = data["attachment_name"] ?: "attachment.bin"
val attachmentType = data["attachment_type"]
@@ -131,12 +134,13 @@ class FirebaseService : FirebaseMessagingService() {
priority = toPriority(priority),
tags = tags ?: "",
click = click ?: "",
+ actions = parser.parseActions(actions),
attachment = attachment,
notificationId = Random.nextInt(),
deleted = false
)
if (repository.addNotification(notification)) {
- Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
+ Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification)
}
}
diff --git a/fastlane/metadata/android/bg/full_description.txt b/fastlane/metadata/android/bg/full_description.txt
index 65a3cf7..4d992bf 100644
--- a/fastlane/metadata/android/bg/full_description.txt
+++ b/fastlane/metadata/android/bg/full_description.txt
@@ -1,6 +1,6 @@
Изпращайте известия към телефона си от всеки скрипт на Bash или PowerShell, или от вашето приложение чрез заявки по PUT/POST, напр. с curl или Invoke-WebRequest.
-Ntfy е клиент за Android за https://ntfy.sh, безплатна услуга с отворен код за абониране и публикуване на основата на HTTP. Абонирайте се за дадена тема в приложението, а после публикувайте съобщения чрез семпъл ППИ на HTTP.
+ntfy е клиент за Android за https://ntfy.sh, безплатна услуга с отворен код за абониране и публикуване на основата на HTTP. Абонирайте се за дадена тема в приложението, а после публикувайте съобщения чрез семпъл ППИ на HTTP.
Употреба:
* Получавайте известия, когато някакъв дълъг процес завърши
diff --git a/fastlane/metadata/android/bg/title.txt b/fastlane/metadata/android/bg/title.txt
index 6d702fd..60a32d6 100644
--- a/fastlane/metadata/android/bg/title.txt
+++ b/fastlane/metadata/android/bg/title.txt
@@ -1 +1 @@
-Ntfy - PUT/POST към телефон
+ntfy - PUT/POST към телефон
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index d2b3b93..80bc551 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,6 +1,6 @@
Send notifications to your phone from any Bash or PowerShell script, or from your own app using PUT/POST requests, e.g. via curl on Linux or Invoke-WebRequest.
-Ntfy is an Android client for https://ntfy.sh, a free and open source HTTP-based pub-sub service. You can subscribe to topics in this app, and then publish messages via a simple HTTP API.
+ntfy is an Android client for https://ntfy.sh, a free and open source HTTP-based pub-sub service. You can subscribe to topics in this app, and then publish messages via a simple HTTP API.
Uses:
* Notify yourself when a long-running process is done
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
index 0d6e441..d33c752 100644
--- a/fastlane/metadata/android/en-US/title.txt
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -1 +1 @@
-Ntfy - PUT/POST to your phone
+ntfy - PUT/POST to your phone
diff --git a/fastlane/metadata/android/es/full_description.txt b/fastlane/metadata/android/es/full_description.txt
index 6429bbd..ffdc146 100644
--- a/fastlane/metadata/android/es/full_description.txt
+++ b/fastlane/metadata/android/es/full_description.txt
@@ -1,6 +1,6 @@
Envíe notificaciones a su teléfono desde cualquier script de Bash o PowerShell, o desde tu propia aplicación utilizando peticiones PUT/POST, por ejemplo, mediante curl en Linux o Invoke-WebRequest.
-Ntfy es un cliente Android para https://ntfy.sh, un servicio pub-sub basado en HTTP, gratuito y de código abierto. Puede suscribirse a tópicos en esta aplicación, y luego publicar mensajes a través de una simple API HTTP.
+ntfy es un cliente Android para https://ntfy.sh, un servicio pub-sub basado en HTTP, gratuito y de código abierto. Puede suscribirse a tópicos en esta aplicación, y luego publicar mensajes a través de una simple API HTTP.
Usos:
* Notificarse a sí mismo cuando un proceso de larga duración ha terminado
diff --git a/fastlane/metadata/android/es/title.txt b/fastlane/metadata/android/es/title.txt
index 590f4a9..8a12a7d 100644
--- a/fastlane/metadata/android/es/title.txt
+++ b/fastlane/metadata/android/es/title.txt
@@ -1 +1 @@
-Ntfy - PUT/POST a su teléfono
+ntfy - PUT/POST a su teléfono
diff --git a/fastlane/metadata/android/ja/full_description.txt b/fastlane/metadata/android/ja/full_description.txt
index a41eb27..3d1ab6c 100644
--- a/fastlane/metadata/android/ja/full_description.txt
+++ b/fastlane/metadata/android/ja/full_description.txt
@@ -1,6 +1,6 @@
スマホに通知を送信します。BashやPowerShellスクリプト、あなたの独自アプリから、例えばLinuxのcurlやInvoke-WebRequestを介したPUT/POSTリクエストで送信させることができます。
-Ntfyは無料でオープンソースなHTTPベースのpub-subサービス ( https://ntfy.sh ) のアンドロイドクライアントです。アプリでトピックを購読して、シンプルなHTTP APIでメッセージを送信する事ができます。
+ntfyは無料でオープンソースなHTTPベースのpub-subサービス ( https://ntfy.sh ) のアンドロイドクライアントです。アプリでトピックを購読して、シンプルなHTTP APIでメッセージを送信する事ができます。
用途:
* 長時間処理のプロセス完了時に自分に通知
diff --git a/fastlane/metadata/android/ja/title.txt b/fastlane/metadata/android/ja/title.txt
index 91e0cff..4d0abda 100644
--- a/fastlane/metadata/android/ja/title.txt
+++ b/fastlane/metadata/android/ja/title.txt
@@ -1 +1 @@
-Ntfy - スマホにPUT/POST通知しよう
+ntfy - スマホにPUT/POST通知しよう
diff --git a/fastlane/metadata/android/nb-NO/full_description.txt b/fastlane/metadata/android/nb-NO/full_description.txt
index 6d01bde..85b9d50 100644
--- a/fastlane/metadata/android/nb-NO/full_description.txt
+++ b/fastlane/metadata/android/nb-NO/full_description.txt
@@ -1,6 +1,6 @@
Send merknader til din mobilenhet fra Bash eller PowerShell-skript, eller fra ditt eget program som bruker PUT/POST-forespørsler, f.eks. via cURL på Linux|GNU, eller Invoke-WebRequest.
-Ntfy er en Android-klient for https://ntfy.sh, en gratis og åpen HTTP-basert pub-sub-tjeneste. Du kan abonnere på emner i dette programmet, og kan så publisere meldinger ved et enkelt HTTP-API.
+ntfy er en Android-klient for https://ntfy.sh, en gratis og åpen HTTP-basert pub-sub-tjeneste. Du kan abonnere på emner i dette programmet, og kan så publisere meldinger ved et enkelt HTTP-API.
Bruk:
* Gi deg selv en merknad når en tidkrevende prosess er ferdig
diff --git a/fastlane/metadata/android/nb-NO/title.txt b/fastlane/metadata/android/nb-NO/title.txt
index 066394e..3443e47 100644
--- a/fastlane/metadata/android/nb-NO/title.txt
+++ b/fastlane/metadata/android/nb-NO/title.txt
@@ -1 +1 @@
-Ntfy — PUT/POST til din mobil
+ntfy — PUT/POST til din mobil
diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt
index 8c10dd4..b3045c2 100644
--- a/fastlane/metadata/android/ru/full_description.txt
+++ b/fastlane/metadata/android/ru/full_description.txt
@@ -1,6 +1,6 @@
Отправляйте уведомления на ваш телефон из любого Bash или PowerShell скрипта, или же собственного приложения с использованием PUT/POST запросов, например, через curl на Linux или Invoke-WebRequest.
-Ntfy является Android клиентом для https;//ntfy.sh, бесплатной основанной на HTTP издатель-подписчик (pub-sub) службе с открытым исходным кодом.
+ntfy является Android клиентом для https;//ntfy.sh, бесплатной основанной на HTTP издатель-подписчик (pub-sub) службе с открытым исходным кодом.
Возможные применения:
* Уведомите себя при завершении длительного процесса
diff --git a/fastlane/metadata/android/ru/title.txt b/fastlane/metadata/android/ru/title.txt
index f566fc7..70d5de0 100644
--- a/fastlane/metadata/android/ru/title.txt
+++ b/fastlane/metadata/android/ru/title.txt
@@ -1 +1 @@
-Ntfy - PUT/POST на ваш телефон
+ntfy - PUT/POST на ваш телефон
diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt
index 9618765..ec1e7c6 100644
--- a/fastlane/metadata/android/tr/full_description.txt
+++ b/fastlane/metadata/android/tr/full_description.txt
@@ -1,6 +1,6 @@
Herhangi bir Bash veya PowerShell betiğinden veya kendi uygulamanızdan PUT/POST isteklerini kullanarak telefonunuza bildirimler gönderin, örn. Linux curl ile veya Invoke-WebRequest aracılığıyla.
-Ntfy, özgür ve açık kaynaklı HTTP tabanlı bir yayın-abone hizmeti olan https://ntfy.sh için bir Android istemcisidir. Bu uygulamadaki konulara abone olabilir ve ardından basit bir HTTP API aracılığıyla mesajlar yayınlayabilirsiniz.
+ntfy, özgür ve açık kaynaklı HTTP tabanlı bir yayın-abone hizmeti olan https://ntfy.sh için bir Android istemcisidir. Bu uygulamadaki konulara abone olabilir ve ardından basit bir HTTP API aracılığıyla mesajlar yayınlayabilirsiniz.
Kullanım Alanları:
* Uzun süren bir işlem bittiğinde kendinize haber verin
diff --git a/fastlane/metadata/android/tr/title.txt b/fastlane/metadata/android/tr/title.txt
index a918c98..8cf67e3 100644
--- a/fastlane/metadata/android/tr/title.txt
+++ b/fastlane/metadata/android/tr/title.txt
@@ -1 +1 @@
-Ntfy - Telefonunuza PUT/POST
+ntfy - Telefonunuza PUT/POST
diff --git a/settings.gradle b/settings.gradle
index 6e17991..231bc04 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,2 @@
-rootProject.name='Ntfy'
+rootProject.name='ntfy'
include ':app'