Implement UnifiedPush 2.0 spec (untested, #130)

This commit is contained in:
Philipp Heckel 2022-03-13 15:58:19 -04:00
parent 81483ff3cd
commit bd8d61997d
16 changed files with 376 additions and 35 deletions

View file

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

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "eda2cb9740c4542f24462779eb6ff81d",
"identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669",
"entities": [
{
"tableName": "Subscription",
@ -82,7 +82,7 @@
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `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",
@ -114,6 +114,12 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encoding",
"columnName": "encoding",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
@ -284,7 +290,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eda2cb9740c4542f24462779eb6ff81d')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bab75c3b41c53c9855fe3a7ef8f0669')"
]
}
}

View file

@ -0,0 +1,296 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669",
"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, `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": "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, '5bab75c3b41c53c9855fe3a7ef8f0669')"
]
}
}

View file

@ -108,6 +108,7 @@
<intent-filter>
<action android:name="org.unifiedpush.android.distributor.REGISTER"/>
<action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
<action android:name="org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"/>
</intent-filter>
</receiver>

View file

@ -50,6 +50,7 @@ data class Notification(
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "encoding") val encoding: String, // "base64" or ""
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String,
@ -100,7 +101,7 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 8)
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 9)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@ -122,6 +123,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@ -191,6 +193,12 @@ abstract class Database : RoomDatabase() {
db.execSQL("CREATE TABLE User (baseUrl TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
}
}
private val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
}
}
}
}

View file

@ -2,13 +2,12 @@ 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.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.joinTagsMap
import io.heckel.ntfy.util.splitTags
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -26,7 +25,9 @@ class BroadcastService(private val ctx: Context) {
intent.putExtra("topic", subscription.topic)
intent.putExtra("time", notification.timestamp.toInt())
intent.putExtra("title", notification.title)
intent.putExtra("message", notification.message)
intent.putExtra("message", decodeMessage(notification))
intent.putExtra("message_bytes", decodeBytesMessage(notification))
intent.putExtra("message_encoding", notification.encoding)
intent.putExtra("tags", notification.tags)
intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags)))
intent.putExtra("priority", notification.priority)

View file

@ -1,11 +1,13 @@
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
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.up.Distributor
import io.heckel.ntfy.util.decodeBytesMessage
import io.heckel.ntfy.util.safeLet
/**
@ -37,7 +39,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
if (distribute) {
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
distributor.sendMessage(appId, connectorToken, notification.message)
distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification))
}
}
if (download) {

View file

@ -20,11 +20,6 @@ class NotificationParser {
if (message.event != ApiService.EVENT_MESSAGE) {
return null
}
val decodedMessage = if (message.encoding == MESSAGE_ENCODING_BASE64) {
String(Base64.decode(message.message, Base64.DEFAULT))
} else {
message.message
}
val attachment = if (message.attachment?.url != null) {
Attachment(
name = message.attachment.name,
@ -39,7 +34,8 @@ class NotificationParser {
subscriptionId = subscriptionId,
timestamp = message.time,
title = message.title ?: "",
message = decodedMessage,
message = message.message,
encoding = message.encoding ?: "",
priority = toPriority(message.priority),
tags = joinTags(message.tags),
click = message.click ?: "",

View file

@ -39,7 +39,7 @@ class NotificationService(val context: Context) {
fun cancel(notification: Notification) {
if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
Log.d(TAG, "Cancelling notification ${notification.id}: ${decodeMessage(notification)}")
notificationManager.cancel(notification.notificationId)
}
}

View file

@ -87,8 +87,8 @@ class JsonConnection(
override fun close() {
Log.d(TAG, "[$url] Cancelling connection")
if (this::job.isInitialized) job?.cancel()
if (this::call.isInitialized) call?.cancel()
if (this::job.isInitialized) job.cancel()
if (this::call.isInitialized) call.cancel()
}
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {

View file

@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Html
import android.util.Base64
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
@ -29,6 +30,7 @@ import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.firebase.FirebaseMessenger
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.NotificationService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.*
@ -514,9 +516,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun copyToClipboard(notification: Notification) {
runOnUiThread {
val message = notification.message + "\n\n" + Date(notification.timestamp * 1000).toString()
val message = decodeMessage(notification)
val text = message + "\n\n" + Date(notification.timestamp * 1000).toString()
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("notification message", message)
val clip = ClipData.newPlainText("notification message", text)
clipboard.setPrimaryClip(clip)
Toast
.makeText(this, getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
@ -574,7 +577,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val content = adapter.selected.joinToString("\n\n") { notificationId ->
val notification = repository.getNotification(notificationId)
notification?.let {
it.message + "\n" + Date(it.timestamp * 1000).toString()
decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString()
}.orEmpty()
}
runOnUiThread {

View file

@ -13,7 +13,10 @@ const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"
const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"
const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
const val FEATURE_BYTES_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"
const val EXTRA_APPLICATION = "application"
const val EXTRA_TOKEN = "token"
const val EXTRA_ENDPOINT = "endpoint"
const val EXTRA_MESSAGE = "message"
const val EXTRA_BYTES_MESSAGE = "bytesMessage"

View file

@ -9,13 +9,14 @@ import io.heckel.ntfy.util.Log
* See https://unifiedpush.org/spec/android/ for details.
*/
class Distributor(val context: Context) {
fun sendMessage(app: String, connectorToken: String, message: String) {
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message")
fun sendMessage(app: String, connectorToken: String, message: ByteArray) {
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): ${String(message)} (${message.size} bytes)}")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_MESSAGE
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
broadcastIntent.putExtra(EXTRA_MESSAGE, message)
broadcastIntent.putExtra(EXTRA_MESSAGE, String(message)) // UTF-8
broadcastIntent.putExtra(EXTRA_BYTES_MESSAGE, message)
context.sendBroadcast(broadcastIntent)
}

View file

@ -13,6 +13,7 @@ import android.os.PowerManager
import android.provider.OpenableColumns
import android.text.Editable
import android.text.TextWatcher
import android.util.Base64
import android.util.TypedValue
import android.view.View
import android.view.Window
@ -22,6 +23,7 @@ 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.msg.MESSAGE_ENCODING_BASE64
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
@ -110,21 +112,45 @@ fun unmatchedTags(tags: List<String>): List<String> {
/**
* Prepend tags/emojis to message, but only if there is a non-empty title.
* Otherwise the tags will be prepended to the title.
* Otherwise, the tags will be prepended to the title.
*/
fun formatMessage(notification: Notification): String {
return if (notification.title != "") {
notification.message
decodeMessage(notification)
} else {
val emojis = toEmojis(splitTags(notification.tags))
if (emojis.isEmpty()) {
notification.message
decodeMessage(notification)
} else {
emojis.joinToString("") + " " + notification.message
emojis.joinToString("") + " " + decodeMessage(notification)
}
}
}
fun decodeMessage(notification: Notification): String {
return try {
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
String(Base64.decode(notification.message, Base64.DEFAULT))
} else {
notification.message
}
} catch (e: IllegalArgumentException) {
notification.message + "(invalid base64)"
}
}
fun decodeBytesMessage(notification: Notification): ByteArray {
return try {
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
Base64.decode(notification.message, Base64.DEFAULT)
} else {
notification.message.toByteArray()
}
} catch (e: IllegalArgumentException) {
notification.message.toByteArray()
}
}
/**
* See above; prepend emojis to title if the title is non-empty.
* Otherwise, they are prepended to the message.

View file

@ -112,11 +112,6 @@ class FirebaseService : FirebaseMessagingService() {
}
// Add notification
val decodedMessage = if (encoding == MESSAGE_ENCODING_BASE64) {
String(Base64.decode(message, Base64.DEFAULT))
} else {
message
}
val attachment = if (attachmentUrl != null) {
Attachment(
name = attachmentName,
@ -131,7 +126,8 @@ class FirebaseService : FirebaseMessagingService() {
subscriptionId = subscription.id,
timestamp = timestamp,
title = title ?: "",
message = decodedMessage,
message = message,
encoding = encoding ?: "",
priority = toPriority(priority),
tags = tags ?: "",
click = click ?: "",

View file

@ -0,0 +1,2 @@
Features:
* Support for UnifiedPush 2.0 specification (bytes messages, #130)