commit
3fb8124a67
39 changed files with 817 additions and 118 deletions
|
@ -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"
|
||||
|
|
302
app/schemas/io.heckel.ntfy.db.Database/10.json
Normal file
302
app/schemas/io.heckel.ntfy.db.Database/10.json
Normal file
|
@ -0,0 +1,302 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 10,
|
||||
"identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Notification",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actions",
|
||||
"columnName": "actions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Log",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tag",
|
||||
"columnName": "tag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "level",
|
||||
"columnName": "level",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "exception",
|
||||
"columnName": "exception",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c1b4f54d1d3111dc5c8f02e8fa960ceb')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -123,7 +123,7 @@
|
|||
|
||||
<!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup -->
|
||||
<receiver
|
||||
android:name=".msg.NotificationService$DownloadBroadcastReceiver"
|
||||
android:name=".msg.NotificationService$UserActionBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
</receiver>
|
||||
|
|
|
@ -2,6 +2,7 @@ package io.heckel.ntfy.backup
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.room.ColumnInfo
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.stream.JsonReader
|
||||
|
@ -109,6 +110,25 @@ class Backuper(val context: Context) {
|
|||
}
|
||||
notifications.forEach { n ->
|
||||
try {
|
||||
val actions = if (n.actions != null) {
|
||||
n.actions.map { a ->
|
||||
io.heckel.ntfy.db.Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = a.progress,
|
||||
error = a.error
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val attachment = if (n.attachment != null) {
|
||||
io.heckel.ntfy.db.Attachment(
|
||||
name = n.attachment.name,
|
||||
|
@ -133,6 +153,7 @@ class Backuper(val context: Context) {
|
|||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
))
|
||||
|
@ -201,6 +222,25 @@ class Backuper(val context: Context) {
|
|||
|
||||
private suspend fun createNotificationList(): List<Notification> {
|
||||
return repository.getNotifications().map { n ->
|
||||
val actions = if (n.actions != null) {
|
||||
n.actions.map { a ->
|
||||
Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = a.progress,
|
||||
error = a.error
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val attachment = if (n.attachment != null) {
|
||||
Attachment(
|
||||
name = n.attachment.name,
|
||||
|
@ -224,6 +264,7 @@ class Backuper(val context: Context) {
|
|||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
)
|
||||
|
@ -290,10 +331,25 @@ data class Notification(
|
|||
val priority: Int, // 1=min, 3=default, 5=max
|
||||
val tags: String,
|
||||
val click: String, // URL/intent to open on notification click
|
||||
val actions: List<Action>?,
|
||||
val attachment: Attachment?,
|
||||
val deleted: Boolean
|
||||
)
|
||||
|
||||
data class Action(
|
||||
val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
|
||||
val action: String, // "view", "http" or "broadcast"
|
||||
val label: String,
|
||||
val url: String?, // used in "view" and "http" actions
|
||||
val method: String?, // used in "http" action
|
||||
val headers: Map<String,String>?, // used in "http" action
|
||||
val body: String?, // used in "http" action
|
||||
val intent: String?, // used in "broadcast" action
|
||||
val extras: Map<String,String>?, // used in "broadcast" action
|
||||
val progress: Int?, // used to indicate progress in popup
|
||||
val error: String? // used to indicate errors in popup
|
||||
)
|
||||
|
||||
data class Attachment(
|
||||
val name: String, // Filename
|
||||
val type: String?, // MIME type
|
||||
|
@ -304,7 +360,6 @@ data class Attachment(
|
|||
val progress: Int, // Progress during download, -1 if not downloaded
|
||||
)
|
||||
|
||||
|
||||
data class User(
|
||||
val baseUrl: String,
|
||||
val username: String,
|
||||
|
|
|
@ -4,8 +4,10 @@ import android.content.Context
|
|||
import androidx.room.*
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import io.heckel.ntfy.util.shortUrl
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.lang.reflect.Type
|
||||
|
||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
|
||||
data class Subscription(
|
||||
|
@ -55,6 +57,7 @@ data class Notification(
|
|||
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
|
||||
@ColumnInfo(name = "tags") val tags: String,
|
||||
@ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
|
||||
@ColumnInfo(name = "actions") val actions: List<Action>?,
|
||||
@Embedded(prefix = "attachment_") val attachment: Attachment?,
|
||||
@ColumnInfo(name = "deleted") val deleted: Boolean,
|
||||
)
|
||||
|
@ -70,14 +73,48 @@ data class Attachment(
|
|||
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
|
||||
) {
|
||||
constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
|
||||
this(name, type, size, expires, url, null, PROGRESS_NONE)
|
||||
this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE)
|
||||
}
|
||||
|
||||
const val PROGRESS_NONE = -1
|
||||
const val PROGRESS_INDETERMINATE = -2
|
||||
const val PROGRESS_FAILED = -3
|
||||
const val PROGRESS_DELETED = -4
|
||||
const val PROGRESS_DONE = 100
|
||||
const val ATTACHMENT_PROGRESS_NONE = -1
|
||||
const val ATTACHMENT_PROGRESS_INDETERMINATE = -2
|
||||
const val ATTACHMENT_PROGRESS_FAILED = -3
|
||||
const val ATTACHMENT_PROGRESS_DELETED = -4
|
||||
const val ATTACHMENT_PROGRESS_DONE = 100
|
||||
|
||||
@Entity
|
||||
data class Action(
|
||||
@ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
|
||||
@ColumnInfo(name = "action") val action: String, // "view", "http" or "broadcast"
|
||||
@ColumnInfo(name = "label") val label: String,
|
||||
@ColumnInfo(name = "url") val url: String?, // used in "view" and "http" actions
|
||||
@ColumnInfo(name = "method") val method: String?, // used in "http" action
|
||||
@ColumnInfo(name = "headers") val headers: Map<String,String>?, // used in "http" action
|
||||
@ColumnInfo(name = "body") val body: String?, // used in "http" action
|
||||
@ColumnInfo(name = "intent") val intent: String?, // used in "broadcast" action
|
||||
@ColumnInfo(name = "extras") val extras: Map<String,String>?, // used in "broadcast" action
|
||||
@ColumnInfo(name = "progress") val progress: Int?, // used to indicate progress in popup
|
||||
@ColumnInfo(name = "error") val error: String?, // used to indicate errors in popup
|
||||
)
|
||||
|
||||
const val ACTION_PROGRESS_ONGOING = 1
|
||||
const val ACTION_PROGRESS_SUCCESS = 2
|
||||
const val ACTION_PROGRESS_FAILED = 3
|
||||
|
||||
class Converters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun toActionList(value: String?): List<Action>? {
|
||||
val listType: Type = object : TypeToken<List<Action>?>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromActionList(list: List<Action>?): String {
|
||||
return gson.toJson(list)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity
|
||||
data class User(
|
||||
|
@ -101,7 +138,8 @@ data class LogEntry(
|
|||
this(0, timestamp, tag, level, message, exception)
|
||||
}
|
||||
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 9)
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 10)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class Database : RoomDatabase() {
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
|
@ -124,6 +162,7 @@ abstract class Database : RoomDatabase() {
|
|||
.addMigrations(MIGRATION_6_7)
|
||||
.addMigrations(MIGRATION_7_8)
|
||||
.addMigrations(MIGRATION_8_9)
|
||||
.addMigrations(MIGRATION_9_10)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
this.instance = instance
|
||||
|
@ -199,6 +238,12 @@ abstract class Database : RoomDatabase() {
|
|||
db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import io.heckel.ntfy.db.Action
|
||||
|
||||
/* This annotation ensures that proguard still works in production builds,
|
||||
* see https://stackoverflow.com/a/62753300/1440785 */
|
||||
|
@ -13,6 +14,7 @@ data class Message(
|
|||
val priority: Int?,
|
||||
val tags: List<String>?,
|
||||
val click: String?,
|
||||
val actions: List<MessageAction>?,
|
||||
val title: String?,
|
||||
val message: String,
|
||||
val encoding: String?,
|
||||
|
@ -28,4 +30,17 @@ data class MessageAttachment(
|
|||
val url: String,
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class MessageAction(
|
||||
val id: String,
|
||||
val action: String,
|
||||
val label: String, // "view", "broadcast" or "http"
|
||||
val url: String?, // used in "view" and "http" actions
|
||||
val method: String?, // used in "http" action, default is POST (!)
|
||||
val headers: Map<String,String>?, // used in "http" action
|
||||
val body: String?, // used in "http" action
|
||||
val intent: String?, // used in "broadcast" action
|
||||
val extras: Map<String,String>?, // used in "broadcast" action
|
||||
)
|
||||
|
||||
const val MESSAGE_ENCODING_BASE64 = "base64"
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.util.Base64
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import io.heckel.ntfy.db.Action
|
||||
import io.heckel.ntfy.db.Attachment
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.util.joinTags
|
||||
import io.heckel.ntfy.util.toPriority
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class NotificationParser {
|
||||
private val gson = Gson()
|
||||
|
@ -29,6 +31,11 @@ class NotificationParser {
|
|||
url = message.attachment.url,
|
||||
)
|
||||
} else null
|
||||
val actions = if (message.actions != null) {
|
||||
message.actions.map { a ->
|
||||
Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null)
|
||||
}
|
||||
} else null
|
||||
val notification = Notification(
|
||||
id = message.id,
|
||||
subscriptionId = subscriptionId,
|
||||
|
@ -39,6 +46,7 @@ class NotificationParser {
|
|||
priority = toPriority(message.priority),
|
||||
tags = joinTags(message.tags),
|
||||
click = message.click ?: "",
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
notificationId = notificationId,
|
||||
deleted = false
|
||||
|
@ -46,5 +54,17 @@ class NotificationParser {
|
|||
return NotificationWithTopic(message.topic, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON array to Action list. The indirection via MessageAction is probably
|
||||
* not necessary, but for "good form".
|
||||
*/
|
||||
fun parseActions(s: String?): List<Action>? {
|
||||
val listType: Type = object : TypeToken<List<MessageAction>?>() {}.type
|
||||
val messageActions: List<MessageAction>? = gson.fromJson(s, listType)
|
||||
return messageActions?.map { a ->
|
||||
Action(a.id, a.action, a.label, a.url, a.method, a.headers, a.body, a.intent, a.extras, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
data class NotificationWithTopic(val topic: String, val notification: Notification)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
32
app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt
Normal file
32
app/src/main/java/io/heckel/ntfy/msg/UserActionManager.kt
Normal file
|
@ -0,0 +1,32 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
/**
|
||||
* Trigger user actions clicked from notification popups.
|
||||
*
|
||||
* The indirection via WorkManager is required since this code may be executed
|
||||
* in a doze state and Internet may not be available. It's also best practice, apparently.
|
||||
*/
|
||||
object UserActionManager {
|
||||
private const val TAG = "NtfyUserActionEx"
|
||||
private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_"
|
||||
|
||||
fun enqueue(context: Context, notificationId: String, actionId: String) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = WORK_NAME_PREFIX + notificationId + "_" + actionId
|
||||
Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, action $actionId, work: $workName")
|
||||
val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
UserActionWorker.INPUT_DATA_NOTIFICATION_ID to notificationId,
|
||||
UserActionWorker.INPUT_DATA_ACTION_ID to actionId,
|
||||
))
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
}
|
||||
}
|
107
app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt
Normal file
107
app/src/main/java/io/heckel/ntfy/msg/UserActionWorker.kt
Normal file
|
@ -0,0 +1,107 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP
|
||||
import io.heckel.ntfy.util.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(60, TimeUnit.SECONDS) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val notifier = NotificationService(context)
|
||||
private val broadcaster = BroadcastService(context)
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscription: Subscription
|
||||
private lateinit var notification: Notification
|
||||
private lateinit var action: Action
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (context.applicationContext !is Application) return Result.failure()
|
||||
val notificationId = inputData.getString(INPUT_DATA_NOTIFICATION_ID) ?: return Result.failure()
|
||||
val actionId = inputData.getString(INPUT_DATA_ACTION_ID) ?: return Result.failure()
|
||||
val app = context.applicationContext as Application
|
||||
|
||||
repository = app.repository
|
||||
notification = repository.getNotification(notificationId) ?: return Result.failure()
|
||||
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
|
||||
action = notification.actions?.first { it.id == actionId } ?: return Result.failure()
|
||||
|
||||
Log.d(TAG, "Executing action $action for notification $notification")
|
||||
try {
|
||||
when (action.action) {
|
||||
// ACTION_VIEW is not handled here. It has to be handled in the foreground to avoid
|
||||
// weird Android behavior.
|
||||
|
||||
ACTION_BROADCAST -> performBroadcastAction(action)
|
||||
ACTION_HTTP -> performHttpAction(action)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error executing action: ${e.message}", e)
|
||||
save(action.copy(
|
||||
progress = ACTION_PROGRESS_FAILED,
|
||||
error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message)
|
||||
))
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun performBroadcastAction(action: Action) {
|
||||
broadcaster.sendUserAction(action)
|
||||
}
|
||||
|
||||
private fun performHttpAction(action: Action) {
|
||||
save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null))
|
||||
|
||||
val url = action.url ?: return
|
||||
val method = action.method ?: "POST" // (not GET, because POST as a default makes more sense!)
|
||||
val body = action.body ?: ""
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.method(method, body.toRequestBody())
|
||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||
action.headers?.forEach { (key, value) ->
|
||||
builder.addHeader(key, value)
|
||||
}
|
||||
val request = builder.build()
|
||||
|
||||
Log.d(TAG, "Executing HTTP request: ${method.uppercase(Locale.getDefault())} ${action.url}")
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null))
|
||||
return
|
||||
}
|
||||
throw Exception("HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(newAction: Action) {
|
||||
Log.d(TAG, "Updating action: $newAction")
|
||||
val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a }
|
||||
val newNotification = notification.copy(actions = newActions)
|
||||
action = newAction
|
||||
notification = newNotification
|
||||
notifier.update(subscription, notification)
|
||||
repository.updateNotification(notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INPUT_DATA_NOTIFICATION_ID = "notificationId"
|
||||
const val INPUT_DATA_ACTION_ID = "actionId"
|
||||
|
||||
private const val TAG = "NtfyUserActWrk"
|
||||
}
|
||||
}
|
|
@ -25,6 +25,8 @@ import io.heckel.ntfy.R
|
|||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadWorker
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
|
@ -81,7 +83,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
|
||||
|
||||
dateView.text = formatDateShort(notification.timestamp)
|
||||
messageView.text = formatMessage(notification)
|
||||
messageView.text = maybeAppendActionErrors(formatMessage(notification), notification)
|
||||
newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
|
||||
itemView.setOnClickListener { onClick(notification) }
|
||||
itemView.setOnLongClickListener { onLongClick(notification); true }
|
||||
|
@ -179,6 +181,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
val attachment = notification.attachment // May be null
|
||||
val hasAttachment = attachment != null
|
||||
val hasClickLink = notification.click != ""
|
||||
val hasUserActions = notification.actions?.isNotEmpty() ?: false
|
||||
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
|
||||
val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel)
|
||||
val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
|
||||
|
@ -199,6 +202,12 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
if (hasClickLink) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
if (notification.actions != null && notification.actions.isNotEmpty()) {
|
||||
notification.actions.forEach { action ->
|
||||
val actionItem = popup.menu.add(formatActionLabel(action))
|
||||
actionItem.setOnMenuItemClickListener { runAction(context, notification, action) }
|
||||
}
|
||||
}
|
||||
openItem.isVisible = hasAttachment && exists
|
||||
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && exists
|
||||
|
@ -208,7 +217,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
copyContentsItem.isVisible = notification.click != ""
|
||||
val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
|
||||
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
|
||||
&& !copyContentsItem.isVisible
|
||||
&& !copyContentsItem.isVisible && !hasUserActions
|
||||
if (noOptions) {
|
||||
return null
|
||||
}
|
||||
|
@ -217,10 +226,10 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
|
||||
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String {
|
||||
val name = attachment.name
|
||||
val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
|
||||
val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
|
||||
val downloading = !exists && attachment.progress in 0..99
|
||||
val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED)
|
||||
val failed = !exists && attachment.progress == PROGRESS_FAILED
|
||||
val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED)
|
||||
val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
|
||||
val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
|
||||
val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000
|
||||
val infos = mutableListOf<String>()
|
||||
|
@ -357,7 +366,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
if (!deleted) throw Exception("no rows deleted")
|
||||
val newAttachment = attachment.copy(
|
||||
contentUri = null,
|
||||
progress = PROGRESS_DELETED
|
||||
progress = ATTACHMENT_PROGRESS_DELETED
|
||||
)
|
||||
val newNotification = notification.copy(attachment = newAttachment)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
@ -401,6 +410,31 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
|
|||
copyToClipboard(context, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runAction(context: Context, notification: Notification, action: Action): Boolean {
|
||||
when (action.action) {
|
||||
ACTION_VIEW -> runViewAction(context, action)
|
||||
else -> runOtherUserAction(context, notification, action)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runViewAction(context: Context, action: Action) {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun runOtherUserAction(context: Context, notification: Notification, action: Action) {
|
||||
val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -186,7 +186,7 @@
|
|||
<string name="settings_advanced_export_logs_entry_copy_scrubbed">Копиране в междинната памет (цензурирано)</string>
|
||||
<string name="main_banner_json_stream_text">От юни 2022 г. за връзка със сървърите на ntfy ще се използва WebSockets. Не забравяйте да настроите собствения сървър да го поддържа. За да проверите дали поддръжката на WebSocket работи, разрешете я в Настройки, в раздел Протокол за връзка.</string>
|
||||
<string name="settings_advanced_connection_protocol_summary_jsonhttp">За свързване със сървъра се използва поток от JSON през HTTP. Методът е остарял и ще бъде премахнат през месец юни 2022 год.</string>
|
||||
<string name="detail_test_message">Това е пробно известие от приложението Ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.</string>
|
||||
<string name="detail_test_message">Това е пробно известие от приложението ntfy за Android. То е с приоритет %1$d. Ако изпратите друго, то може да изглежда по различен начин.</string>
|
||||
<string name="detail_test_title">Проба: Ако желаете можете да сложите заглавие</string>
|
||||
<string name="detail_test_message_error_unauthorized_user">Грешка при изпращане: Потребителят „%1$s“ няма достъп.</string>
|
||||
<string name="settings_notifications_min_priority_summary_x_or_higher">Показват се известията с приоритет %1$d (%2$s) или по-висок</string>
|
||||
|
|
|
@ -205,7 +205,7 @@
|
|||
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON-strøm over HTTP</string>
|
||||
<string name="settings_advanced_connection_protocol_entry_ws">Vev-sockets</string>
|
||||
<string name="user_dialog_description_add">Du kan legge til en bruker her som du kan tilknytte et gitt emne senere.</string>
|
||||
<string name="settings_about_version_format">Ntfy %1$s (%2$s)</string>
|
||||
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
|
||||
<string name="user_dialog_password_hint_edit">Passord (uendret hvis tomt)</string>
|
||||
<string name="user_dialog_button_add">Legg til bruker</string>
|
||||
<string name="user_dialog_button_cancel">Avbryt</string>
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
<string name="add_dialog_login_error_not_authorized">登录失败。用户 %1$s 无权访问。</string>
|
||||
<string name="add_dialog_login_new_user">新用户</string>
|
||||
<string name="detail_no_notifications_text">目前还没有关于此主题的通知。</string>
|
||||
<string name="main_banner_json_stream_text">请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 Ntfy 服务器的推送。</string>
|
||||
<string name="main_banner_json_stream_text">请在“链接协议”中选择 WebSockets 以保证在 2022 年 6 月之后仍能收到来自自建 ntfy 服务器的推送。</string>
|
||||
<string name="main_banner_json_stream_button_remind_later">稍后再问</string>
|
||||
<string name="main_banner_json_stream_button_dismiss">暂时不管</string>
|
||||
<string name="main_banner_json_stream_button_learn_more">详情</string>
|
||||
|
@ -56,7 +56,7 @@
|
|||
<string name="add_dialog_topic_name_hint">主题名称,比如:phils_alerts</string>
|
||||
<string name="detail_how_to_link">详细的说明请见 ntfy.sh 和帮助文档。</string>
|
||||
<string name="detail_clear_dialog_message">您确认要删除这个主题下的所有通知吗?</string>
|
||||
<string name="detail_test_message">这是 Ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。</string>
|
||||
<string name="detail_test_message">这是 ntfy 安卓应用发来的测试通知。此通知优先级为 %1$d。如果再发送一条通知,通知的样式可能有变化。</string>
|
||||
<string name="detail_test_message_error_unauthorized_user">无法发送消息:用户 %1$s 无权发布。</string>
|
||||
<string name="detail_item_menu_download">下载文件</string>
|
||||
<string name="detail_item_menu_save_file">保存文件</string>
|
||||
|
|
|
@ -220,6 +220,7 @@
|
|||
<string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string>
|
||||
<string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, downloaded</string>
|
||||
<string name="notification_popup_file_download_failed">%1$s\nFile: %2$s, download failed</string>
|
||||
<string name="notification_popup_user_action_failed">"%1$s" failed: %2$s</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
The translatable="false" attribute is just an additional safety. -->
|
||||
|
||||
<!-- Main app constants -->
|
||||
<string name="app_name" translatable="false">Ntfy</string>
|
||||
<string name="app_name" translatable="false">ntfy</string>
|
||||
<string name="app_base_url" translatable="false">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
|
||||
|
||||
<!-- Main activity -->
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
Употреба:
|
||||
* Получавайте известия, когато някакъв дълъг процес завърши
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - PUT/POST към телефон
|
||||
ntfy - PUT/POST към телефон
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - PUT/POST to your phone
|
||||
ntfy - PUT/POST to your phone
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - PUT/POST a su teléfono
|
||||
ntfy - PUT/POST a su teléfono
|
||||
|
|
|
@ -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でメッセージを送信する事ができます。
|
||||
|
||||
用途:
|
||||
* 長時間処理のプロセス完了時に自分に通知
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - スマホにPUT/POST通知しよう
|
||||
ntfy - スマホにPUT/POST通知しよう
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy — PUT/POST til din mobil
|
||||
ntfy — PUT/POST til din mobil
|
||||
|
|
|
@ -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) службе с открытым исходным кодом.
|
||||
|
||||
Возможные применения:
|
||||
* Уведомите себя при завершении длительного процесса
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - PUT/POST на ваш телефон
|
||||
ntfy - PUT/POST на ваш телефон
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
Ntfy - Telefonunuza PUT/POST
|
||||
ntfy - Telefonunuza PUT/POST
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
rootProject.name='Ntfy'
|
||||
rootProject.name='ntfy'
|
||||
include ':app'
|
||||
|
|
Loading…
Reference in a new issue