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> </intent-filter>
</receiver> </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) --> <!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service <service
android:name=".firebase.FirebaseService" android:name=".firebase.FirebaseService"

View file

@ -13,13 +13,15 @@ data class Subscription(
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "instant") val instant: Boolean,
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule @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 totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications @Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) { ) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) : constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE) this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
} }
enum class ConnectionState { enum class ConnectionState {
@ -32,6 +34,8 @@ data class SubscriptionWithMetadata(
val topic: String, val topic: String,
val instant: Boolean, val instant: Boolean,
val mutedUntil: Long, val mutedUntil: Long,
val upAppId: String,
val upConnectorToken: String,
val totalCount: Int, val totalCount: Int,
val newCount: Int, val newCount: Int,
val lastActive: Long val lastActive: Long
@ -50,7 +54,7 @@ data class Notification(
@ColumnInfo(name = "deleted") val deleted: Boolean, @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 class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao abstract fun notificationDao(): NotificationDao
@ -66,6 +70,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3) .addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4) .addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
this.instance = instance this.instance = instance
@ -102,6 +107,13 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification_New RENAME TO Notification") 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 { interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -122,7 +134,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -135,7 +147,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -148,7 +160,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive 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 detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
val muted = isMuted(notification.subscriptionId) val muted = isMuted(notification.subscriptionId)
val notify = !detailsVisible && !muted 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") @Suppress("RedundantSuspendModifier")
@ -177,6 +177,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
@ -195,6 +197,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
@ -225,8 +229,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
} }
data class NotificationAddResult( data class NotificationAddResult(
val notification: Notification,
val notify: Boolean, val notify: Boolean,
val broadcast: Boolean, val broadcast: Boolean,
val forward: Boolean, // Forward to UnifiedPush connector
val muted: Boolean, 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.app.Application
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl 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.work.PollWorker
import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.BroadcastService import io.heckel.ntfy.msg.*
import io.heckel.ntfy.msg.SubscriberService
import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -54,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Other stuff // Other stuff
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent private var workManager: WorkManager? = null // Context-dependent
private var dispatcher: NotificationDispatcher? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent
private var broadcaster: BroadcastService? = null // Context-dependent private var broadcaster: BroadcastService? = null // Context-dependent
private var subscriberManager: SubscriberManager? = 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 // Dependencies that depend on Context
workManager = WorkManager.getInstance(this) workManager = WorkManager.getInstance(this)
dispatcher = NotificationDispatcher(this)
notifier = NotificationService(this) notifier = NotificationService(this)
broadcaster = BroadcastService(this) broadcaster = BroadcastService(this)
subscriberManager = SubscriberManager(this) subscriberManager = SubscriberManager(this)
@ -288,6 +287,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
topic = topic, topic = topic,
instant = instant, instant = instant,
mutedUntil = 0, mutedUntil = 0,
upAppId = "",
upConnectorToken = "",
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
@ -342,6 +343,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newNotificationsCount++ newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt()) val notificationWithId = notification.copy(notificationId = Random.nextInt())
val result = repository.addNotification(notificationWithId) val result = repository.addNotification(notificationWithId)
dispatcher?.dispatch()
if (result.notify) { if (result.notify) {
notifier?.send(subscription, notificationWithId) 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.* import java.util.*
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" 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 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 topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1"
fun topicShortUrl(baseUrl: String, topic: String) = fun topicShortUrl(baseUrl: String, topic: String) =