WIP: UnifiedPush

This commit is contained in:
Philipp Heckel 2021-12-29 20:33:17 +01:00
parent 2387f2ce6c
commit 94e595110d
10 changed files with 341 additions and 13 deletions

View file

@ -0,0 +1,150 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "d72d045ad4ad20db887b4c6aed3da27b",
"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 NOT NULL, `upConnectorToken` TEXT NOT NULL, 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": true
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
}
],
"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, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, 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": "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": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"subscriptionId"
],
"autoGenerate": false
},
"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, 'd72d045ad4ad20db887b4c6aed3da27b')"
]
}
}

View file

@ -64,6 +64,14 @@
</intent-filter>
</receiver>
<!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md -->
<receiver android:name=".up.BroadcastReceiver" android:enabled="true" android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
</intent-filter>
</receiver>
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service
android:name=".firebase.FirebaseService"

View file

@ -13,13 +13,15 @@ data class Subscription(
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean,
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
@ColumnInfo(name = "upAppId") val upAppId: String,
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String,
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) :
this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@ -32,6 +34,8 @@ data class SubscriptionWithMetadata(
val topic: String,
val instant: Boolean,
val mutedUntil: Long,
val upAppId: String,
val upConnectorToken: String,
val totalCount: Int,
val newCount: Int,
val lastActive: Long
@ -50,7 +54,7 @@ data class Notification(
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 4)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 5)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@ -66,6 +70,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@ -102,6 +107,13 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
}
}
private val MIGRATION_4_5 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT NOT NULL DEFAULT('')")
db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT NOT NULL DEFAULT('')")
}
}
}
}
@ -109,7 +121,7 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -122,7 +134,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -135,7 +147,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -148,7 +160,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive

View file

@ -92,9 +92,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
val muted = isMuted(notification.subscriptionId)
val notify = !detailsVisible && !muted
return NotificationAddResult(notify = notify, broadcast = true, muted = muted)
return NotificationAddResult(notification = notification, notify = notify, broadcast = true, muted = muted)
}
return NotificationAddResult(notify = false, broadcast = false, muted = false)
return NotificationAddResult(notification = notification, notify = false, broadcast = false, forward = false, muted = false)
}
@Suppress("RedundantSuspendModifier")
@ -177,6 +177,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@ -195,6 +197,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@ -225,8 +229,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
}
data class NotificationAddResult(
val notification: Notification,
val notify: Boolean,
val broadcast: Boolean,
val forward: Boolean, // Forward to UnifiedPush connector
val muted: Boolean,
)

View file

@ -0,0 +1,31 @@
package io.heckel.ntfy.msg
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.formatMessage
import io.heckel.ntfy.util.formatTitle
class NotificationDispatcher(val context: Context) {
fun dispatch(subscription: Subscription, notification: Notification) {
}
companion object {
private const val TAG = "NtfyNotificationDispatcher"
}
}

View file

@ -20,12 +20,9 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.work.PollWorker
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.BroadcastService
import io.heckel.ntfy.msg.SubscriberService
import io.heckel.ntfy.msg.*
import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.Dispatchers
@ -54,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Other stuff
private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent
private var dispatcher: NotificationDispatcher? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
private var broadcaster: BroadcastService? = null // Context-dependent
private var subscriberManager: SubscriberManager? = null // Context-dependent
@ -67,6 +65,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Dependencies that depend on Context
workManager = WorkManager.getInstance(this)
dispatcher = NotificationDispatcher(this)
notifier = NotificationService(this)
broadcaster = BroadcastService(this)
subscriberManager = SubscriberManager(this)
@ -288,6 +287,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
topic = topic,
instant = instant,
mutedUntil = 0,
upAppId = "",
upConnectorToken = "",
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
@ -342,6 +343,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt())
val result = repository.addNotification(notificationWithId)
dispatcher?.dispatch()
if (result.notify) {
notifier?.send(subscription, notificationWithId)
}

View file

@ -0,0 +1,62 @@
package io.heckel.ntfy.up
import android.content.Context
import android.content.Intent
import android.util.Log
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random
class BroadcastReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent!!.action) {
ACTION_REGISTER -> {
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: ""
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
Log.d(TAG, "Register: app=$appId, connectorToken=$connectorToken")
if (appId.isBlank()) {
Log.w(TAG, "Trying to register an app without packageName")
return
}
val baseUrl = context!!.getString(R.string.app_base_url) // FIXME
val topic = connectorToken // FIXME
val app = context!!.applicationContext as Application
val repository = app.repository
val subscription = Subscription(
id = Random.nextLong(),
baseUrl = baseUrl,
topic = topic,
instant = true,
mutedUntil = 0,
upAppId = appId,
upConnectorToken = connectorToken,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
)
GlobalScope.launch(Dispatchers.IO) {
repository.addSubscription(subscription)
}
sendEndpoint(context!!, appId, connectorToken)
// XXXXXXXXX
}
ACTION_UNREGISTER -> {
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
Log.d(TAG, "Unregister: connectorToken=$connectorToken")
// XXXXXXX
sendUnregistered(context!!, "org.unifiedpush.example", connectorToken)
}
}
}
companion object {
private const val TAG = "NtfyUpBroadcastRecv"
}
}

View file

@ -0,0 +1,22 @@
package io.heckel.ntfy.up
/**
* Constants as defined on the specs
* https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md
*/
const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"
const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"
const val ACTION_REGISTRATION_REFUSED = "org.unifiedpush.android.connector.REGISTRATION_REFUSED"
const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"
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 ACTION_MESSAGE_ACK = "org.unifiedpush.android.distributor.MESSAGE_ACK"
const val EXTRA_APPLICATION = "application"
const val EXTRA_TOKEN = "token"
const val EXTRA_ENDPOINT = "endpoint"
const val EXTRA_MESSAGE = "message"
const val EXTRA_MESSAGE_ID = "id"

View file

@ -0,0 +1,34 @@
package io.heckel.ntfy.up
import android.content.Context
import android.content.Intent
import io.heckel.ntfy.R
import io.heckel.ntfy.util.topicUrlUp
fun sendMessage(context: Context, app: String, token: String, message: String) {
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_MESSAGE
broadcastIntent.putExtra(EXTRA_TOKEN, token)
broadcastIntent.putExtra(EXTRA_MESSAGE, message)
context.sendBroadcast(broadcastIntent)
}
fun sendEndpoint(context: Context, app: String, token: String) {
val appBaseUrl = context.getString(R.string.app_base_url)
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_NEW_ENDPOINT
broadcastIntent.putExtra(EXTRA_TOKEN, token)
broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token))
context.sendBroadcast(broadcastIntent)
}
fun sendUnregistered(context: Context, app: String, token: String) {
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_UNREGISTERED
broadcastIntent.putExtra(EXTRA_TOKEN, token)
context.sendBroadcast(broadcastIntent)
}

View file

@ -9,6 +9,7 @@ import java.text.DateFormat
import java.util.*
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
fun topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1"
fun topicShortUrl(baseUrl: String, topic: String) =