From 94e595110d825fb9031c489d40c27d986b4139ca Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Wed, 29 Dec 2021 20:33:17 +0100
Subject: [PATCH 01/16] WIP: UnifiedPush

---
 .../io.heckel.ntfy.data.Database/5.json       | 150 ++++++++++++++++++
 app/src/main/AndroidManifest.xml              |   8 +
 .../main/java/io/heckel/ntfy/data/Database.kt |  26 ++-
 .../java/io/heckel/ntfy/data/Repository.kt    |  10 +-
 .../heckel/ntfy/msg/NotificationDispatcher.kt |  31 ++++
 .../java/io/heckel/ntfy/ui/MainActivity.kt    |  10 +-
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    |  62 ++++++++
 .../main/java/io/heckel/ntfy/up/Constants.kt  |  22 +++
 .../io/heckel/ntfy/up/DistributorUtils.kt     |  34 ++++
 app/src/main/java/io/heckel/ntfy/util/Util.kt |   1 +
 10 files changed, 341 insertions(+), 13 deletions(-)
 create mode 100644 app/schemas/io.heckel.ntfy.data.Database/5.json
 create mode 100644 app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
 create mode 100644 app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
 create mode 100644 app/src/main/java/io/heckel/ntfy/up/Constants.kt
 create mode 100644 app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt

diff --git a/app/schemas/io.heckel.ntfy.data.Database/5.json b/app/schemas/io.heckel.ntfy.data.Database/5.json
new file mode 100644
index 0000000..0620854
--- /dev/null
+++ b/app/schemas/io.heckel.ntfy.data.Database/5.json
@@ -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')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 931bab1..1beaca1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -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"
diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 7457cfe..240342b 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -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
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index ccff84f..5caacd4 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -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,
     )
 
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
new file mode 100644
index 0000000..f981e05
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -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"
+    }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index a1b9a9e..a4d03b6 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -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)
                         }
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
new file mode 100644
index 0000000..1fa8c61
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -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"
+    }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/up/Constants.kt b/app/src/main/java/io/heckel/ntfy/up/Constants.kt
new file mode 100644
index 0000000..6ee8b41
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/Constants.kt
@@ -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"
diff --git a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt
new file mode 100644
index 0000000..a7ba475
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt
@@ -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)
+}
+
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index b5f4a08..1d8dcff 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -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) =

From 7e9da287049656f9ca879b71bf1c2d97b268e6a5 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Wed, 29 Dec 2021 21:36:47 +0100
Subject: [PATCH 02/16] Forward messages, don't show ntfy notification

---
 .../java/io/heckel/ntfy/data/Repository.kt    | 23 ++------
 .../heckel/ntfy/msg/NotificationDispatcher.kt | 55 +++++++++++++------
 .../io/heckel/ntfy/msg/SubscriberService.kt   | 18 ++----
 .../java/io/heckel/ntfy/ui/MainActivity.kt    | 17 ++----
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    |  6 +-
 .../java/io/heckel/ntfy/up/Distributor.kt     | 36 ++++++++++++
 .../io/heckel/ntfy/up/DistributorUtils.kt     | 34 ------------
 .../java/io/heckel/ntfy/work/PollWorker.kt    | 13 ++---
 .../heckel/ntfy/firebase/FirebaseService.kt   | 21 ++-----
 9 files changed, 104 insertions(+), 119 deletions(-)
 create mode 100644 app/src/main/java/io/heckel/ntfy/up/Distributor.kt
 delete mode 100644 app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt

diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 5caacd4..e953911 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -85,16 +85,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
 
     @Suppress("RedundantSuspendModifier")
     @WorkerThread
-    suspend fun addNotification(notification: Notification): NotificationAddResult {
+    suspend fun addNotification(notification: Notification): Boolean {
         val maybeExistingNotification = notificationDao.get(notification.id)
-        if (maybeExistingNotification == null) {
-            notificationDao.add(notification)
-            val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
-            val muted = isMuted(notification.subscriptionId)
-            val notify = !detailsVisible && !muted
-            return NotificationAddResult(notification = notification, notify = notify, broadcast = true, muted = muted)
+        if (maybeExistingNotification != null) {
+            return false
         }
-        return NotificationAddResult(notification = notification, notify = false, broadcast = false, forward = false, muted = false)
+        notificationDao.add(notification)
+        return true
     }
 
     @Suppress("RedundantSuspendModifier")
@@ -141,7 +138,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
     }
 
-    private fun isGlobalMuted(): Boolean {
+    fun isGlobalMuted(): Boolean {
         val mutedUntil = getGlobalMutedUntil()
         return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
     }
@@ -228,14 +225,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
     }
 
-    data class NotificationAddResult(
-        val notification: Notification,
-        val notify: Boolean,
-        val broadcast: Boolean,
-        val forward: Boolean, // Forward to UnifiedPush connector
-        val muted: Boolean,
-    )
-
     companion object {
         const val SHARED_PREFS_ID = "MainPreferences"
         const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index f981e05..d4bade2 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -1,28 +1,49 @@
 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.Repository
 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
+import io.heckel.ntfy.up.Distributor
+
+class NotificationDispatcher(val context: Context, val repository: Repository) {
+    private val notifier = NotificationService(context)
+    private val broadcaster = BroadcastService(context)
+    private val distributor = Distributor(context)
+
+    fun init() {
+        notifier.createNotificationChannels()
+    }
 
-class NotificationDispatcher(val context: Context) {
     fun dispatch(subscription: Subscription, notification: Notification) {
+        val muted = checkMuted(subscription)
+        val notify = checkNotify(subscription, notification, muted)
+        val broadcast = subscription.upAppId == ""
+        val distribute = subscription.upAppId != ""
+        if (notify) {
+            notifier.send(subscription, notification)
+        }
+        if (broadcast) {
+            broadcaster.send(subscription, notification, muted)
+        }
+        if (distribute) {
+            distributor.sendMessage(subscription.upAppId, subscription.upConnectorToken, notification.message)
+        }
+    }
 
+    private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
+        if (subscription.upAppId != "") {
+            return false
+        }
+        val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId
+        return !detailsVisible && !muted
+    }
+
+    private fun checkMuted(subscription: Subscription): Boolean {
+        if (repository.isGlobalMuted()) {
+            return true
+        }
+        return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
     }
 
     companion object {
diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
index 8a0add5..90187bd 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
@@ -58,10 +58,9 @@ class SubscriberService : Service() {
     private var wakeLock: PowerManager.WakeLock? = null
     private var isServiceStarted = false
     private val repository by lazy { (application as Application).repository }
+    private val dispatcher by lazy { NotificationDispatcher(this, repository) }
     private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
     private val api = ApiService()
-    private val notifier = NotificationService(this)
-    private val broadcaster = BroadcastService(this)
     private var notificationManager: NotificationManager? = null
     private var serviceNotification: Notification? = null
 
@@ -201,18 +200,13 @@ class SubscriberService : Service() {
         repository.updateState(subscriptionIds, state)
     }
 
-    private fun onNotificationReceived(subscription: Subscription, n: io.heckel.ntfy.data.Notification) {
+    private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) {
         val url = topicUrl(subscription.baseUrl, subscription.topic)
-        Log.d(TAG, "[$url] Received notification: $n")
+        Log.d(TAG, "[$url] Received notification: $notification")
         GlobalScope.launch(Dispatchers.IO) {
-            val result = repository.addNotification(n)
-            if (result.notify) {
-                Log.d(TAG, "[$url] Showing notification: $n")
-                notifier.send(subscription, n)
-            }
-            if (result.broadcast) {
-                Log.d(TAG, "[$url] Broadcasting notification: $n")
-                broadcaster.send(subscription, n, result.muted)
+            if (repository.addNotification(notification)) {
+                Log.d(TAG, "[$url] Dispatching notification $notification")
+                dispatcher.dispatch(subscription, notification)
             }
         }
     }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index a4d03b6..7bd46c6 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -52,8 +52,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
     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
     private var appBaseUrl: String? = null // Context-dependent
 
@@ -65,9 +63,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)
+        dispatcher = NotificationDispatcher(this, repository)
         subscriberManager = SubscriberManager(this)
         appBaseUrl = getString(R.string.app_base_url)
 
@@ -113,7 +109,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         }
 
         // Create notification channels right away, so we can configure them immediately after installing the app
-        notifier!!.createNotificationChannels()
+        dispatcher?.init()
 
         // Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
         messenger.subscribe(ApiService.CONTROL_TOPIC)
@@ -342,13 +338,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
                     newNotifications.forEach { notification ->
                         newNotificationsCount++
                         val notificationWithId = notification.copy(notificationId = Random.nextInt())
-                        val result = repository.addNotification(notificationWithId)
-                        dispatcher?.dispatch()
-                        if (result.notify) {
-                            notifier?.send(subscription, notificationWithId)
-                        }
-                        if (result.broadcast) {
-                            broadcaster?.send(subscription, notification, result.muted)
+                        if (repository.addNotification(notificationWithId)) {
+                            dispatcher?.dispatch(subscription, notificationWithId)
                         }
                     }
                 } catch (e: Exception) {
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 1fa8c61..7e56035 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -28,6 +28,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                 val topic = connectorToken // FIXME
                 val app = context!!.applicationContext as Application
                 val repository = app.repository
+                val distributor = Distributor(app)
                 val subscription = Subscription(
                     id = Random.nextLong(),
                     baseUrl = baseUrl,
@@ -44,14 +45,15 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                     repository.addSubscription(subscription)
                 }
 
-                sendEndpoint(context!!, appId, connectorToken)
+                distributor.sendEndpoint(appId, connectorToken)
                 // XXXXXXXXX
             }
             ACTION_UNREGISTER -> {
                 val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
                 Log.d(TAG, "Unregister: connectorToken=$connectorToken")
                 // XXXXXXX
-                sendUnregistered(context!!, "org.unifiedpush.example", connectorToken)
+                val distributor = Distributor(context!!)
+                distributor.sendUnregistered("org.unifiedpush.example", connectorToken)
             }
         }
     }
diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
new file mode 100644
index 0000000..975ab58
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
@@ -0,0 +1,36 @@
+package io.heckel.ntfy.up
+
+import android.content.Context
+import android.content.Intent
+import io.heckel.ntfy.R
+import io.heckel.ntfy.data.Repository
+import io.heckel.ntfy.util.topicUrlUp
+
+class Distributor(val context: Context) {
+    fun sendMessage(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(app: String, token: String) {
+        val appBaseUrl = context.getString(R.string.app_base_url) // FIXME
+        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(app: String, token: String) {
+        val broadcastIntent = Intent()
+        broadcastIntent.`package` = app
+        broadcastIntent.action = ACTION_UNREGISTERED
+        broadcastIntent.putExtra(EXTRA_TOKEN, token)
+        context.sendBroadcast(broadcastIntent)
+    }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt
deleted file mode 100644
index a7ba475..0000000
--- a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-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)
-}
-
diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
index 8828e23..43e8003 100644
--- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
+++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
@@ -7,8 +7,10 @@ import androidx.work.WorkerParameters
 import io.heckel.ntfy.BuildConfig
 import io.heckel.ntfy.data.Database
 import io.heckel.ntfy.data.Repository
+import io.heckel.ntfy.firebase.FirebaseService
 import io.heckel.ntfy.msg.ApiService
 import io.heckel.ntfy.msg.BroadcastService
+import io.heckel.ntfy.msg.NotificationDispatcher
 import io.heckel.ntfy.msg.NotificationService
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
@@ -25,8 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
             val database = Database.getInstance(applicationContext)
             val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
             val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
-            val notifier = NotificationService(applicationContext)
-            val broadcaster = BroadcastService(applicationContext)
+            val dispatcher = NotificationDispatcher(applicationContext, repository)
             val api = ApiService()
 
             repository.getSubscriptions().forEach{ subscription ->
@@ -36,12 +37,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
                         .onlyNewNotifications(subscription.id, notifications)
                         .map { it.copy(notificationId = Random.nextInt()) }
                     newNotifications.forEach { notification ->
-                        val result = repository.addNotification(notification)
-                        if (result.notify) {
-                            notifier.send(subscription, notification)
-                        }
-                        if (result.broadcast) {
-                            broadcaster.send(subscription, notification, result.muted)
+                        if (repository.addNotification(notification)) {
+                            dispatcher.dispatch(subscription, notification)
                         }
                     }
                 } catch (e: Exception) {
diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
index 61caac9..1df310c 100644
--- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
+++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
@@ -7,10 +7,7 @@ import com.google.firebase.messaging.RemoteMessage
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Notification
-import io.heckel.ntfy.msg.ApiService
-import io.heckel.ntfy.msg.BroadcastService
-import io.heckel.ntfy.msg.NotificationService
-import io.heckel.ntfy.msg.SubscriberService
+import io.heckel.ntfy.msg.*
 import io.heckel.ntfy.util.toPriority
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob
@@ -19,9 +16,8 @@ import kotlin.random.Random
 
 class FirebaseService : FirebaseMessagingService() {
     private val repository by lazy { (application as Application).repository }
+    private val dispatcher by lazy { NotificationDispatcher(this, repository) }
     private val job = SupervisorJob()
-    private val notifier = NotificationService(this)
-    private val broadcaster = BroadcastService(this)
     private val messenger = FirebaseMessenger()
 
     override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -81,16 +77,9 @@ class FirebaseService : FirebaseMessagingService() {
                 tags = tags ?: "",
                 deleted = false
             )
-            val result = repository.addNotification(notification)
-
-            // Send notification (only if it's not already known)
-            if (result.notify) {
-                Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
-                notifier.send(subscription, notification)
-            }
-            if (result.broadcast) {
-                Log.d(TAG, "Sending broadcast for message: from=${remoteMessage.from}, data=${data}")
-                broadcaster.send(subscription, notification, result.muted)
+            if (repository.addNotification(notification)) {
+                Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, data=${data}")
+                dispatcher.dispatch(subscription, notification)
             }
         }
     }

From 7dbbf12c9903c4648e1bcc04f86088d940773f65 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Wed, 29 Dec 2021 23:48:06 +0100
Subject: [PATCH 03/16] Start instant delivery notification

---
 .../main/java/io/heckel/ntfy/data/Repository.kt  |  6 ++++++
 .../main/java/io/heckel/ntfy/ui/MainActivity.kt  |  2 +-
 .../java/io/heckel/ntfy/ui/SubscriberManager.kt  | 16 +++++++++-------
 .../java/io/heckel/ntfy/up/BroadcastReceiver.kt  |  5 ++++-
 4 files changed, 20 insertions(+), 9 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index e953911..fa6797a 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -36,6 +36,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         return toSubscriptionList(subscriptionDao.list())
     }
 
+    fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
+        return subscriptionDao
+            .list()
+            .map { Pair(it.id, it.instant) }.toSet()
+    }
+
     @Suppress("RedundantSuspendModifier")
     @WorkerThread
     suspend fun getSubscription(subscriptionId: Long): Subscription? {
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 7bd46c6..3a3e813 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
             }
         }
 
-        // React to changes in fast delivery setting
+        // React to changes in instant delivery setting
         viewModel.listIdsWithInstantStatus().observe(this) {
             subscriberManager?.refreshService(it)
         }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
index a348d8a..141096e 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
@@ -1,5 +1,6 @@
 package io.heckel.ntfy.ui
 
+import android.content.Context
 import android.content.Intent
 import android.os.Build
 import android.util.Log
@@ -7,16 +8,17 @@ import androidx.activity.ComponentActivity
 import androidx.lifecycle.lifecycleScope
 import io.heckel.ntfy.msg.SubscriberService
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
 
 /**
  * This class only manages the SubscriberService, i.e. it starts or stops it.
  * It's used in multiple activities.
  */
-class SubscriberManager(private val activity: ComponentActivity) {
-    fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) {
+class SubscriberManager(private val context: Context) {
+    fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) { // Set<SubscriptionId -> IsInstant>
         Log.d(MainActivity.TAG, "Triggering subscriber service refresh")
-        activity.lifecycleScope.launch(Dispatchers.IO) {
+        GlobalScope.launch(Dispatchers.IO) {
             val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
             if (instantSubscriptions == 0) {
                 performActionOnSubscriberService(SubscriberService.Actions.STOP)
@@ -27,18 +29,18 @@ class SubscriberManager(private val activity: ComponentActivity) {
     }
 
     private fun performActionOnSubscriberService(action: SubscriberService.Actions) {
-        val serviceState = SubscriberService.readServiceState(activity)
+        val serviceState = SubscriberService.readServiceState(context)
         if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) {
             return
         }
-        val intent = Intent(activity, SubscriberService::class.java)
+        val intent = Intent(context, SubscriberService::class.java)
         intent.action = action.name
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
             Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
-            activity.startForegroundService(intent)
+            context.startForegroundService(intent)
         } else {
             Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
-            activity.startService(intent)
+            context.startService(intent)
         }
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 7e56035..71c36bb 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -6,6 +6,7 @@ import android.util.Log
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Subscription
+import io.heckel.ntfy.ui.SubscriberManager
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
@@ -43,8 +44,10 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                 )
                 GlobalScope.launch(Dispatchers.IO) {
                     repository.addSubscription(subscription)
+                    val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
+                    val subscriberManager = SubscriberManager(context!!)
+                    subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
                 }
-
                 distributor.sendEndpoint(appId, connectorToken)
                 // XXXXXXXXX
             }

From 73f610afa8f3458ee784954962be8a70b2368a50 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Thu, 30 Dec 2021 01:05:32 +0100
Subject: [PATCH 04/16] Full end to end use case works; still ugly though

---
 .../io.heckel.ntfy.data.Database/5.json       | 18 ++++--
 .../main/java/io/heckel/ntfy/data/Database.kt | 25 ++++++--
 .../java/io/heckel/ntfy/data/Repository.kt    |  6 ++
 .../heckel/ntfy/msg/NotificationDispatcher.kt |  9 ++-
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    | 60 ++++++++++++-------
 .../java/io/heckel/ntfy/up/Distributor.kt     | 26 ++++----
 app/src/main/java/io/heckel/ntfy/util/Util.kt | 12 ++++
 7 files changed, 111 insertions(+), 45 deletions(-)

diff --git a/app/schemas/io.heckel.ntfy.data.Database/5.json b/app/schemas/io.heckel.ntfy.data.Database/5.json
index 0620854..dd398a4 100644
--- a/app/schemas/io.heckel.ntfy.data.Database/5.json
+++ b/app/schemas/io.heckel.ntfy.data.Database/5.json
@@ -2,11 +2,11 @@
   "formatVersion": 1,
   "database": {
     "version": 5,
-    "identityHash": "d72d045ad4ad20db887b4c6aed3da27b",
+    "identityHash": "306578182c2ad0f9803956beda094d28",
     "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`))",
+        "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",
@@ -42,13 +42,13 @@
             "fieldPath": "upAppId",
             "columnName": "upAppId",
             "affinity": "TEXT",
-            "notNull": true
+            "notNull": false
           },
           {
             "fieldPath": "upConnectorToken",
             "columnName": "upConnectorToken",
             "affinity": "TEXT",
-            "notNull": true
+            "notNull": false
           }
         ],
         "primaryKey": {
@@ -66,6 +66,14 @@
               "topic"
             ],
             "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
+          },
+          {
+            "name": "index_Subscription_upConnectorToken",
+            "unique": true,
+            "columnNames": [
+              "upConnectorToken"
+            ],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
           }
         ],
         "foreignKeys": []
@@ -144,7 +152,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, 'd72d045ad4ad20db887b4c6aed3da27b')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '306578182c2ad0f9803956beda094d28')"
     ]
   }
 }
\ No newline at end of file
diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 240342b..ad87a96 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -6,15 +6,15 @@ import androidx.room.migration.Migration
 import androidx.sqlite.db.SupportSQLiteDatabase
 import kotlinx.coroutines.flow.Flow
 
-@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
+@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
 data class Subscription(
     @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
     @ColumnInfo(name = "baseUrl") val baseUrl: String,
     @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,
+    @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
@@ -110,8 +110,8 @@ abstract class Database : RoomDatabase() {
 
         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('')")
+                db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
+                db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
             }
         }
     }
@@ -166,11 +166,24 @@ interface SubscriptionDao {
           IFNULL(MAX(n.timestamp),0) AS lastActive
         FROM Subscription AS s
         LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
-        WHERE  s.id = :subscriptionId
+        WHERE s.id = :subscriptionId
         GROUP BY s.id
     """)
     fun get(subscriptionId: Long): SubscriptionWithMetadata?
 
+    @Query("""
+        SELECT 
+          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
+        FROM Subscription AS s
+        LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+        WHERE s.upConnectorToken = :connectorToken
+        GROUP BY s.id
+    """)
+    fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata?
+
     @Insert
     fun add(subscription: Subscription)
 
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index fa6797a..1d1a2cc 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -54,6 +54,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         return toSubscription(subscriptionDao.get(baseUrl, topic))
     }
 
+    @Suppress("RedundantSuspendModifier")
+    @WorkerThread
+    suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? {
+        return toSubscription(subscriptionDao.getByConnectorToken(connectorToken))
+    }
+
     @Suppress("RedundantSuspendModifier")
     @WorkerThread
     suspend fun addSubscription(subscription: Subscription) {
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index d4bade2..aa251cd 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -5,6 +5,7 @@ import io.heckel.ntfy.data.Notification
 import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.up.Distributor
+import io.heckel.ntfy.util.safeLet
 
 class NotificationDispatcher(val context: Context, val repository: Repository) {
     private val notifier = NotificationService(context)
@@ -18,8 +19,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
     fun dispatch(subscription: Subscription, notification: Notification) {
         val muted = checkMuted(subscription)
         val notify = checkNotify(subscription, notification, muted)
-        val broadcast = subscription.upAppId == ""
-        val distribute = subscription.upAppId != ""
+        val broadcast = subscription.upAppId == null
+        val distribute = subscription.upAppId != null
         if (notify) {
             notifier.send(subscription, notification)
         }
@@ -27,7 +28,9 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
             broadcaster.send(subscription, notification, muted)
         }
         if (distribute) {
-            distributor.sendMessage(subscription.upAppId, subscription.upConnectorToken, notification.message)
+            safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
+                distributor.sendMessage(appId, connectorToken, notification.message)
+            }
         }
     }
 
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 71c36bb..125c766 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -7,6 +7,8 @@ import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.ui.SubscriberManager
+import io.heckel.ntfy.util.randomString
+import io.heckel.ntfy.util.topicUrlUp
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
@@ -24,44 +26,62 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                     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 topic = "up" + randomString(TOPIC_LENGTH)
+                val endpoint = topicUrlUp(baseUrl, topic)
                 val app = context!!.applicationContext as Application
                 val repository = app.repository
                 val distributor = Distributor(app)
-                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) {
+                    val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
+                    if (existingSubscription != null) {
+                        distributor.sendRegistrationRefused(appId, connectorToken)
+                        return@launch
+                    }
+                    val subscription = Subscription(
+                        id = Random.nextLong(),
+                        baseUrl = baseUrl,
+                        topic = topic,
+                        instant = true, // No Firebase, always instant!
+                        mutedUntil = 0,
+                        upAppId = appId,
+                        upConnectorToken = connectorToken,
+                        totalCount = 0,
+                        newCount = 0,
+                        lastActive = Date().time/1000
+                    )
                     repository.addSubscription(subscription)
                     val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
-                    val subscriberManager = SubscriberManager(context!!)
+                    val subscriberManager = SubscriberManager(app)
                     subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
+                    distributor.sendEndpoint(appId, connectorToken, endpoint)
                 }
-                distributor.sendEndpoint(appId, connectorToken)
-                // XXXXXXXXX
             }
             ACTION_UNREGISTER -> {
                 val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
                 Log.d(TAG, "Unregister: connectorToken=$connectorToken")
-                // XXXXXXX
-                val distributor = Distributor(context!!)
-                distributor.sendUnregistered("org.unifiedpush.example", connectorToken)
+                val app = context!!.applicationContext as Application
+                val repository = app.repository
+                val distributor = Distributor(app)
+                GlobalScope.launch(Dispatchers.IO) {
+                    val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
+                    if (existingSubscription == null) {
+                        return@launch
+                    }
+                    repository.removeSubscription(existingSubscription.id)
+                    val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
+                    val subscriberManager = SubscriberManager(app)
+                    subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
+                    existingSubscription.upAppId?.let { appId ->
+                        distributor.sendUnregistered(appId, connectorToken)
+                    }
+                }
             }
         }
     }
 
     companion object {
         private const val TAG = "NtfyUpBroadcastRecv"
+        private const val TOPIC_LENGTH = 16
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
index 975ab58..9bdaf7e 100644
--- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
@@ -2,35 +2,39 @@ package io.heckel.ntfy.up
 
 import android.content.Context
 import android.content.Intent
-import io.heckel.ntfy.R
-import io.heckel.ntfy.data.Repository
-import io.heckel.ntfy.util.topicUrlUp
 
 class Distributor(val context: Context) {
-    fun sendMessage(app: String, token: String, message: String) {
+    fun sendMessage(app: String, connectorToken: String, message: String) {
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_MESSAGE
-        broadcastIntent.putExtra(EXTRA_TOKEN, token)
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
         broadcastIntent.putExtra(EXTRA_MESSAGE, message)
         context.sendBroadcast(broadcastIntent)
     }
 
-    fun sendEndpoint(app: String, token: String) {
-        val appBaseUrl = context.getString(R.string.app_base_url) // FIXME
+    fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_NEW_ENDPOINT
-        broadcastIntent.putExtra(EXTRA_TOKEN, token)
-        broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token))
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
+        broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint)
         context.sendBroadcast(broadcastIntent)
     }
 
-    fun sendUnregistered(app: String, token: String) {
+    fun sendUnregistered(app: String, connectorToken: String) {
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_UNREGISTERED
-        broadcastIntent.putExtra(EXTRA_TOKEN, token)
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
+        context.sendBroadcast(broadcastIntent)
+    }
+
+    fun sendRegistrationRefused(app: String, connectorToken: String) {
+        val broadcastIntent = Intent()
+        broadcastIntent.`package` = app
+        broadcastIntent.action = ACTION_REGISTRATION_REFUSED
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
         context.sendBroadcast(broadcastIntent)
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index 1d8dcff..1b88747 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -5,6 +5,7 @@ import android.animation.ValueAnimator
 import android.view.Window
 import io.heckel.ntfy.data.Notification
 import io.heckel.ntfy.data.Subscription
+import java.security.SecureRandom
 import java.text.DateFormat
 import java.util.*
 
@@ -102,3 +103,14 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
     }
     statusBarColorAnimation.start()
 }
+
+fun randomString(len: Int): String {
+    val random = SecureRandom()
+    val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
+    return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
+}
+
+// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
+inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
+    return if (p1 != null && p2 != null) block(p1, p2) else null
+}

From 4efdce54efc03a772e0787d8b8f3f2d8b7d8d31f Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Thu, 30 Dec 2021 14:23:47 +0100
Subject: [PATCH 05/16] Works and is not super ugly

---
 .../main/java/io/heckel/ntfy/data/Database.kt |   4 +-
 .../java/io/heckel/ntfy/ui/MainActivity.kt    |  16 +-
 .../java/io/heckel/ntfy/ui/MainAdapter.kt     |  10 +-
 .../java/io/heckel/ntfy/ui/MainViewModel.kt   |   9 +-
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    | 149 +++++++++++-------
 .../java/io/heckel/ntfy/up/Distributor.kt     |   9 ++
 app/src/main/res/values/strings.xml           |   5 +-
 7 files changed, 130 insertions(+), 72 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index ad87a96..e0187ce 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -34,8 +34,8 @@ data class SubscriptionWithMetadata(
     val topic: String,
     val instant: Boolean,
     val mutedUntil: Long,
-    val upAppId: String,
-    val upConnectorToken: String,
+    val upAppId: String?,
+    val upConnectorToken: String?,
     val totalCount: Int,
     val newCount: Int,
     val lastActive: Long
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 3a3e813..01222fa 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -283,8 +283,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
             topic = topic,
             instant = instant,
             mutedUntil = 0,
-            upAppId = "",
-            upConnectorToken = "",
+            upAppId = null,
+            upConnectorToken = null,
             totalCount = 0,
             newCount = 0,
             lastActive = Date().time/1000
@@ -314,11 +314,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
     private fun onSubscriptionItemClick(subscription: Subscription) {
         if (actionMode != null) {
             handleActionModeClick(subscription)
+        } else if (subscription.upAppId != null) { // Not UnifiedPush
+            displayUnifiedPushToast(subscription)
         } else {
             startDetailView(subscription)
         }
     }
 
+    private fun displayUnifiedPushToast(subscription: Subscription) {
+        runOnUiThread {
+            val appId = subscription.upAppId ?: ""
+            val toastMessage = getString(R.string.main_unified_push_toast, appId)
+            Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
+        }
+    }
+
     private fun onSubscriptionItemLongClick(subscription: Subscription) {
         if (actionMode == null) {
             beginActionMode(subscription)
@@ -415,7 +425,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         val dialog = builder
             .setMessage(R.string.main_action_mode_delete_dialog_message)
             .setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
-                adapter.selected.map { viewModel.remove(it) }
+                adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
                 finishActionMode()
             }
             .setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
index adf52a5..dfbc016 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
@@ -55,7 +55,9 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
 
         fun bind(subscription: Subscription) {
             this.subscription = subscription
-            var statusMessage = if (subscription.totalCount == 1) {
+            var statusMessage = if (subscription.upAppId != null) {
+                context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
+            } else if (subscription.totalCount == 1) {
                 context.getString(R.string.main_item_status_text_one, subscription.totalCount)
             } else {
                 context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
@@ -82,11 +84,11 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
             notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
             notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
             instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
-            if (subscription.newCount > 0) {
+            if (subscription.upAppId != null || subscription.newCount == 0) {
+                newItemsView.visibility = View.GONE
+            } else {
                 newItemsView.visibility = View.VISIBLE
                 newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
-            } else {
-                newItemsView.visibility = View.GONE
             }
             itemView.setOnClickListener { onClick(subscription) }
             itemView.setOnLongClickListener { onLongClick(subscription); true }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
index f0b0275..9c24520 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt
@@ -1,10 +1,12 @@
 package io.heckel.ntfy.ui
 
+import android.content.Context
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
 import androidx.lifecycle.viewModelScope
 import io.heckel.ntfy.data.*
+import io.heckel.ntfy.up.Distributor
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlin.collections.List
@@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
         repository.addSubscription(subscription)
     }
 
-    fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
+    fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
+        val subscription = repository.getSubscription(subscriptionId) ?: return@launch
+        if (subscription.upAppId != null && subscription.upConnectorToken != null) {
+            val distributor = Distributor(context)
+            distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
+        }
         repository.removeAllNotifications(subscriptionId)
         repository.removeSubscription(subscriptionId)
     }
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 125c766..74fba12 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -5,6 +5,7 @@ import android.content.Intent
 import android.util.Log
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
+import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.ui.SubscriberManager
 import io.heckel.ntfy.util.randomString
@@ -17,71 +18,99 @@ 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 = "up" + randomString(TOPIC_LENGTH)
-                val endpoint = topicUrlUp(baseUrl, topic)
-                val app = context!!.applicationContext as Application
-                val repository = app.repository
-                val distributor = Distributor(app)
-                GlobalScope.launch(Dispatchers.IO) {
-                    val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
-                    if (existingSubscription != null) {
-                        distributor.sendRegistrationRefused(appId, connectorToken)
-                        return@launch
-                    }
-                    val subscription = Subscription(
-                        id = Random.nextLong(),
-                        baseUrl = baseUrl,
-                        topic = topic,
-                        instant = true, // No Firebase, always instant!
-                        mutedUntil = 0,
-                        upAppId = appId,
-                        upConnectorToken = connectorToken,
-                        totalCount = 0,
-                        newCount = 0,
-                        lastActive = Date().time/1000
-                    )
-                    repository.addSubscription(subscription)
-                    val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
-                    val subscriberManager = SubscriberManager(app)
-                    subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
-                    distributor.sendEndpoint(appId, connectorToken, endpoint)
-                }
-            }
-            ACTION_UNREGISTER -> {
-                val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
-                Log.d(TAG, "Unregister: connectorToken=$connectorToken")
-                val app = context!!.applicationContext as Application
-                val repository = app.repository
-                val distributor = Distributor(app)
-                GlobalScope.launch(Dispatchers.IO) {
-                    val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
-                    if (existingSubscription == null) {
-                        return@launch
-                    }
-                    repository.removeSubscription(existingSubscription.id)
-                    val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
-                    val subscriberManager = SubscriberManager(app)
-                    subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
-                    existingSubscription.upAppId?.let { appId ->
-                        distributor.sendUnregistered(appId, connectorToken)
-                    }
-                }
-            }
+        if (context == null || intent == null) {
+            return
         }
+        when (intent.action) {
+            ACTION_REGISTER -> register(context, intent)
+            ACTION_UNREGISTER -> unregister(context, intent)
+        }
+    }
+
+    private fun register(context: Context, intent: Intent) {
+        val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return
+        val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
+        val app = context.applicationContext as Application
+        val repository = app.repository
+        val distributor = Distributor(app)
+        Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
+        if (appId.isBlank()) {
+            Log.w(TAG, "Refusing registration: empty application")
+            distributor.sendRegistrationRefused(appId, connectorToken)
+            return
+        }
+        GlobalScope.launch(Dispatchers.IO) {
+            val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
+            if (existingSubscription != null) {
+                if (existingSubscription.upAppId == appId) {
+                    val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic)
+                    Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.")
+                    distributor.sendEndpoint(appId, connectorToken, endpoint)
+                } else {
+                    Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
+                    distributor.sendRegistrationRefused(appId, connectorToken)
+                }
+                return@launch
+            }
+            val baseUrl = context.getString(R.string.app_base_url) // FIXME
+            val topic = UP_PREFIX + randomString(TOPIC_LENGTH)
+            val endpoint = topicUrlUp(baseUrl, topic)
+            val subscription = Subscription(
+                id = Random.nextLong(),
+                baseUrl = baseUrl,
+                topic = topic,
+                instant = true, // No Firebase, always instant!
+                mutedUntil = 0,
+                upAppId = appId,
+                upConnectorToken = connectorToken,
+                totalCount = 0,
+                newCount = 0,
+                lastActive = Date().time/1000
+            )
+
+            // Add subscription
+            Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
+            repository.addSubscription(subscription)
+            distributor.sendEndpoint(appId, connectorToken, endpoint)
+
+            // Refresh (and maybe start) foreground service
+            refreshSubscriberService(app, repository)
+        }
+    }
+
+    private fun unregister(context: Context, intent: Intent) {
+        val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
+        val app = context.applicationContext as Application
+        val repository = app.repository
+        val distributor = Distributor(app)
+        Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)")
+        GlobalScope.launch(Dispatchers.IO) {
+            val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
+            if (existingSubscription == null) {
+                Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.")
+                return@launch
+            }
+
+            // Remove subscription
+            Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken")
+            repository.removeSubscription(existingSubscription.id)
+            existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
+
+            // Refresh (and maybe stop) foreground service
+            refreshSubscriberService(app, repository)
+        }
+    }
+
+    private fun refreshSubscriberService(context: Context, repository: Repository) {
+        Log.d(TAG, "Refreshing subscriber service")
+        val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
+        val subscriberManager = SubscriberManager(context)
+        subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
     }
 
     companion object {
         private const val TAG = "NtfyUpBroadcastRecv"
+        private const val UP_PREFIX = "up"
         private const val TOPIC_LENGTH = 16
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
index 9bdaf7e..a4c1ad9 100644
--- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
@@ -2,9 +2,11 @@ package io.heckel.ntfy.up
 
 import android.content.Context
 import android.content.Intent
+import android.util.Log
 
 class Distributor(val context: Context) {
     fun sendMessage(app: String, connectorToken: String, message: String) {
+        Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message")
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_MESSAGE
@@ -14,6 +16,7 @@ class Distributor(val context: Context) {
     }
 
     fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
+        Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint")
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_NEW_ENDPOINT
@@ -23,6 +26,7 @@ class Distributor(val context: Context) {
     }
 
     fun sendUnregistered(app: String, connectorToken: String) {
+        Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)")
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_UNREGISTERED
@@ -31,10 +35,15 @@ class Distributor(val context: Context) {
     }
 
     fun sendRegistrationRefused(app: String, connectorToken: String) {
+        Log.d(TAG, "Sending REGISTRATION_REFUSED to $app (token=$connectorToken)")
         val broadcastIntent = Intent()
         broadcastIntent.`package` = app
         broadcastIntent.action = ACTION_REGISTRATION_REFUSED
         broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
         context.sendBroadcast(broadcastIntent)
     }
+
+    companion object {
+        private const val TAG = "NtfyUpDistributor"
+    }
 }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5fe54e6..f3fbb2e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -47,6 +47,7 @@
     <string name="main_item_status_text_one">%1$d notification</string>
     <string name="main_item_status_text_not_one">%1$d notifications</string>
     <string name="main_item_status_reconnecting">reconnecting …</string>
+    <string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
     <string name="main_item_date_yesterday">Yesterday</string>
     <string name="main_add_button_description">Add subscription</string>
     <string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
@@ -54,8 +55,8 @@
         Click the button below to create or subscribe to a topic. After that, you can send
         messages via PUT or POST and you\'ll receive notifications on your phone.
     </string>
-    <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
-    </string>
+    <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
+    <string name="main_unified_push_toast">Subscription is managed by %1$s via UnifiedPush</string>
 
     <!-- Add dialog -->
     <string name="add_dialog_title">Subscribe to topic</string>

From 1cca29df564ef57cbd29aa83f27ae1cb334c250b Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Thu, 30 Dec 2021 17:00:27 +0100
Subject: [PATCH 06/16] Refactor subscriber manager (service starter)

---
 app/src/main/AndroidManifest.xml              |  6 +--
 .../heckel/ntfy/msg/NotificationDispatcher.kt |  2 +-
 .../{msg => service}/SubscriberConnection.kt  |  3 +-
 .../{msg => service}/SubscriberService.kt     | 38 +++----------
 .../ntfy/service/SubscriberServiceManager.kt  | 54 +++++++++++++++++++
 .../java/io/heckel/ntfy/ui/DetailActivity.kt  | 11 ++--
 .../java/io/heckel/ntfy/ui/MainActivity.kt    | 20 +++----
 .../io/heckel/ntfy/ui/SubscriberManager.kt    | 46 ----------------
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    | 16 ++----
 .../heckel/ntfy/firebase/FirebaseService.kt   |  1 +
 10 files changed, 89 insertions(+), 108 deletions(-)
 rename app/src/main/java/io/heckel/ntfy/{msg => service}/SubscriberConnection.kt (98%)
 rename app/src/main/java/io/heckel/ntfy/{msg => service}/SubscriberService.kt (89%)
 create mode 100644 app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
 delete mode 100644 app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1beaca1..2fbf176 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,17 +44,17 @@
         </activity>
 
         <!-- Subscriber foreground service for hosts other than ntfy.sh -->
-        <service android:name=".msg.SubscriberService"/>
+        <service android:name=".service.SubscriberService"/>
 
         <!-- Subscriber service restart on reboot -->
-        <receiver android:name=".msg.SubscriberService$BootStartReceiver" android:enabled="true">
+        <receiver android:name=".service.SubscriberService$BootStartReceiver" android:enabled="true">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED"/>
             </intent-filter>
         </receiver>
 
         <!-- Subscriber service restart on destruction -->
-        <receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true"
+        <receiver android:name=".service.SubscriberService$AutoRestartReceiver" android:enabled="true"
                   android:exported="false"/>
 
         <!-- Broadcast receiver to send messages via intents -->
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index aa251cd..18eb5b1 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -35,7 +35,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
     }
 
     private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
-        if (subscription.upAppId != "") {
+        if (subscription.upAppId != null) {
             return false
         }
         val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId
diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt
similarity index 98%
rename from app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt
rename to app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt
index e26cd68..e8ae782 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt
+++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt
@@ -1,9 +1,10 @@
-package io.heckel.ntfy.msg
+package io.heckel.ntfy.service
 
 import android.util.Log
 import io.heckel.ntfy.data.ConnectionState
 import io.heckel.ntfy.data.Notification
 import io.heckel.ntfy.data.Subscription
+import io.heckel.ntfy.msg.ApiService
 import io.heckel.ntfy.util.topicUrl
 import kotlinx.coroutines.*
 import okhttp3.Call
diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
similarity index 89%
rename from app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
rename to app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
index 90187bd..d000268 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
+++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
@@ -1,4 +1,4 @@
-package io.heckel.ntfy.msg
+package io.heckel.ntfy.service
 
 import android.app.*
 import android.content.BroadcastReceiver
@@ -11,8 +11,6 @@ import android.os.SystemClock
 import android.util.Log
 import androidx.core.app.NotificationCompat
 import androidx.core.content.ContextCompat
-import androidx.work.OneTimeWorkRequest
-import androidx.work.WorkManager
 import androidx.work.Worker
 import androidx.work.WorkerParameters
 import io.heckel.ntfy.BuildConfig
@@ -20,6 +18,8 @@ import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.ConnectionState
 import io.heckel.ntfy.data.Subscription
+import io.heckel.ntfy.msg.ApiService
+import io.heckel.ntfy.msg.NotificationDispatcher
 import io.heckel.ntfy.ui.MainActivity
 import io.heckel.ntfy.util.topicUrl
 import kotlinx.coroutines.*
@@ -70,8 +70,8 @@ class SubscriberService : Service() {
             val action = intent.action
             Log.d(TAG, "using an intent with action $action")
             when (action) {
-                Actions.START.name -> startService()
-                Actions.STOP.name -> stopService()
+                Action.START.name -> startService()
+                Action.STOP.name -> stopService()
                 else -> Log.e(TAG, "This should never happen. No action in the received intent")
             }
         } else {
@@ -259,13 +259,7 @@ class SubscriberService : Service() {
     class BootStartReceiver : BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
             Log.d(TAG, "BootStartReceiver: onReceive called")
-            if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) {
-                Intent(context, SubscriberService::class.java).also {
-                    it.action = Actions.START.name
-                    Log.d(TAG, "BootStartReceiver: Starting subscriber service")
-                    ContextCompat.startForegroundService(context, it)
-                }
-            }
+            SubscriberServiceManager.refresh(context)
         }
     }
 
@@ -276,27 +270,11 @@ class SubscriberService : Service() {
     class AutoRestartReceiver : BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
             Log.d(TAG, "AutoRestartReceiver: onReceive called")
-            val workManager = WorkManager.getInstance(context)
-            val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build()
-            workManager.enqueue(startServiceRequest)
+            SubscriberServiceManager.refresh(context)
         }
     }
 
-    class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
-        override fun doWork(): Result {
-            Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId())
-            if (readServiceState(context) == ServiceState.STARTED) {
-                Intent(context, SubscriberService::class.java).also {
-                    it.action = Actions.START.name
-                    Log.d(TAG, "AutoRestartReceiver: Starting subscriber service")
-                    ContextCompat.startForegroundService(context, it)
-                }
-            }
-            return Result.success()
-        }
-    }
-
-    enum class Actions {
+    enum class Action {
         START,
         STOP
     }
diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
new file mode 100644
index 0000000..e3d6174
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
@@ -0,0 +1,54 @@
+package io.heckel.ntfy.service
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.work.*
+import io.heckel.ntfy.app.Application
+import io.heckel.ntfy.up.BroadcastReceiver
+
+/**
+ * This class only manages the SubscriberService, i.e. it starts or stops it.
+ * It's used in multiple activities.
+ */
+class SubscriberServiceManager(private val context: Context) {
+    fun refresh() {
+        Log.d(TAG, "Enqueuing work to refresh subscriber service")
+        val workManager = WorkManager.getInstance(context)
+        val startServiceRequest = OneTimeWorkRequest.Builder(RefreshWorker::class.java).build()
+        workManager.enqueue(startServiceRequest)
+    }
+
+    class RefreshWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
+        override fun doWork(): Result {
+            if (context.applicationContext !is Application) {
+                Log.d(TAG, "RefreshWorker: Failed, no application found (work ID: ${this.id})")
+                return Result.failure()
+            }
+            val app = context.applicationContext as Application
+            val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
+            val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
+            val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP
+            val serviceState = SubscriberService.readServiceState(context)
+            if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
+                return Result.success()
+            }
+            Log.d(TAG, "RefreshWorker: Starting foreground service with action $action (work ID: ${this.id})")
+            Intent(context, SubscriberService::class.java).also {
+                it.action = action.name
+                ContextCompat.startForegroundService(context, it)
+            }
+            return Result.success()
+        }
+    }
+
+    companion object {
+        const val TAG = "NtfySubscriberMgr"
+
+        fun refresh(context: Context) {
+            val manager = SubscriberServiceManager(context)
+            manager.refresh()
+        }
+    }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
index 9757c1f..4477a1b 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
@@ -4,9 +4,6 @@ import android.app.AlertDialog
 import android.content.ClipData
 import android.content.ClipboardManager
 import android.content.Context
-import android.content.Intent
-import android.content.Intent.ACTION_VIEW
-import android.net.Uri
 import android.os.Bundle
 import android.text.Html
 import android.util.Log
@@ -26,12 +23,12 @@ import io.heckel.ntfy.BuildConfig
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Notification
-import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.util.topicShortUrl
 import io.heckel.ntfy.util.topicUrl
 import io.heckel.ntfy.firebase.FirebaseMessenger
 import io.heckel.ntfy.msg.ApiService
 import io.heckel.ntfy.msg.NotificationService
+import io.heckel.ntfy.service.SubscriberServiceManager
 import io.heckel.ntfy.util.fadeStatusBarColor
 import io.heckel.ntfy.util.formatDateShort
 import kotlinx.coroutines.*
@@ -45,7 +42,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
     private val repository by lazy { (application as Application).repository }
     private val api = ApiService()
     private val messenger = FirebaseMessenger()
-    private var subscriberManager: SubscriberManager? = null // Context-dependent
+    private var serviceManager: SubscriberServiceManager? = null // Context-dependent
     private var notifier: NotificationService? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
@@ -72,7 +69,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
         Log.d(MainActivity.TAG, "Create $this")
 
         // Dependencies that depend on Context
-        subscriberManager = SubscriberManager(this)
+        serviceManager = SubscriberServiceManager(this)
         notifier = NotificationService(this)
         appBaseUrl = getString(R.string.app_base_url)
 
@@ -149,7 +146,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
 
         // React to changes in fast delivery setting
         repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
-            subscriberManager?.refreshService(it)
+            serviceManager?.refresh()
         }
 
         // Mark this subscription as "open" so we don't receive notifications for it
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 01222fa..9829aa4 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -23,6 +23,8 @@ import io.heckel.ntfy.util.topicShortUrl
 import io.heckel.ntfy.work.PollWorker
 import io.heckel.ntfy.firebase.FirebaseMessenger
 import io.heckel.ntfy.msg.*
+import io.heckel.ntfy.service.SubscriberService
+import io.heckel.ntfy.service.SubscriberServiceManager
 import io.heckel.ntfy.util.fadeStatusBarColor
 import io.heckel.ntfy.util.formatDateShort
 import kotlinx.coroutines.Dispatchers
@@ -52,7 +54,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
     private var actionMode: ActionMode? = null
     private var workManager: WorkManager? = null // Context-dependent
     private var dispatcher: NotificationDispatcher? = null // Context-dependent
-    private var subscriberManager: SubscriberManager? = null // Context-dependent
+    private var serviceManager: SubscriberServiceManager? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -64,7 +66,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         // Dependencies that depend on Context
         workManager = WorkManager.getInstance(this)
         dispatcher = NotificationDispatcher(this, repository)
-        subscriberManager = SubscriberManager(this)
+        serviceManager = SubscriberServiceManager(this)
         appBaseUrl = getString(R.string.app_base_url)
 
         // Action bar
@@ -105,7 +107,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
         // React to changes in instant delivery setting
         viewModel.listIdsWithInstantStatus().observe(this) {
-            subscriberManager?.refreshService(it)
+            serviceManager?.refresh()
         }
 
         // Create notification channels right away, so we can configure them immediately after installing the app
@@ -116,7 +118,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
         // Background things
         startPeriodicPollWorker()
-        startPeriodicAutoRestartWorker()
+        startPeriodicServiceRefreshWorker()
     }
 
     private fun startPeriodicPollWorker() {
@@ -141,7 +143,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
     }
 
-    private fun startPeriodicAutoRestartWorker() {
+    private fun startPeriodicServiceRefreshWorker() {
         val workerVersion = repository.getAutoRestartWorkerVersion()
         val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) {
             Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy")
@@ -151,12 +153,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
             repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION)
             ExistingPeriodicWorkPolicy.REPLACE
         }
-        val work = PeriodicWorkRequestBuilder<SubscriberService.AutoRestartWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
+        val work = PeriodicWorkRequestBuilder<SubscriberServiceManager.RefreshWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
             .addTag(SubscriberService.TAG)
             .addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC)
             .build()
-        Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes")
-        workManager!!.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
+        Log.d(TAG, "Auto restart worker: Scheduling period work every $MINIMUM_PERIODIC_WORKER_INTERVAL minutes")
+        workManager?.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
     }
 
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -323,7 +325,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
     private fun displayUnifiedPushToast(subscription: Subscription) {
         runOnUiThread {
-            val appId = subscription.upAppId ?: ""
+            val appId = subscription.upAppId ?: return@runOnUiThread
             val toastMessage = getString(R.string.main_unified_push_toast, appId)
             Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
         }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
deleted file mode 100644
index 141096e..0000000
--- a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package io.heckel.ntfy.ui
-
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.util.Log
-import androidx.activity.ComponentActivity
-import androidx.lifecycle.lifecycleScope
-import io.heckel.ntfy.msg.SubscriberService
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-
-/**
- * This class only manages the SubscriberService, i.e. it starts or stops it.
- * It's used in multiple activities.
- */
-class SubscriberManager(private val context: Context) {
-    fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) { // Set<SubscriptionId -> IsInstant>
-        Log.d(MainActivity.TAG, "Triggering subscriber service refresh")
-        GlobalScope.launch(Dispatchers.IO) {
-            val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
-            if (instantSubscriptions == 0) {
-                performActionOnSubscriberService(SubscriberService.Actions.STOP)
-            } else {
-                performActionOnSubscriberService(SubscriberService.Actions.START)
-            }
-        }
-    }
-
-    private fun performActionOnSubscriberService(action: SubscriberService.Actions) {
-        val serviceState = SubscriberService.readServiceState(context)
-        if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) {
-            return
-        }
-        val intent = Intent(context, SubscriberService::class.java)
-        intent.action = action.name
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-            Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
-            context.startForegroundService(intent)
-        } else {
-            Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
-            context.startService(intent)
-        }
-    }
-}
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 74fba12..345ca31 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -7,7 +7,7 @@ import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.data.Subscription
-import io.heckel.ntfy.ui.SubscriberManager
+import io.heckel.ntfy.service.SubscriberServiceManager
 import io.heckel.ntfy.util.randomString
 import io.heckel.ntfy.util.topicUrlUp
 import kotlinx.coroutines.Dispatchers
@@ -52,6 +52,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                 }
                 return@launch
             }
+
+            // Add subscription
             val baseUrl = context.getString(R.string.app_base_url) // FIXME
             val topic = UP_PREFIX + randomString(TOPIC_LENGTH)
             val endpoint = topicUrlUp(baseUrl, topic)
@@ -68,13 +70,12 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                 lastActive = Date().time/1000
             )
 
-            // Add subscription
             Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
             repository.addSubscription(subscription)
             distributor.sendEndpoint(appId, connectorToken, endpoint)
 
             // Refresh (and maybe start) foreground service
-            refreshSubscriberService(app, repository)
+            SubscriberServiceManager.refresh(app)
         }
     }
 
@@ -97,17 +98,10 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
             existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
 
             // Refresh (and maybe stop) foreground service
-            refreshSubscriberService(app, repository)
+            SubscriberServiceManager.refresh(context)
         }
     }
 
-    private fun refreshSubscriberService(context: Context, repository: Repository) {
-        Log.d(TAG, "Refreshing subscriber service")
-        val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
-        val subscriberManager = SubscriberManager(context)
-        subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
-    }
-
     companion object {
         private const val TAG = "NtfyUpBroadcastRecv"
         private const val UP_PREFIX = "up"
diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
index 1df310c..a06ea2c 100644
--- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
+++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt
@@ -8,6 +8,7 @@ import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Notification
 import io.heckel.ntfy.msg.*
+import io.heckel.ntfy.service.SubscriberService
 import io.heckel.ntfy.util.toPriority
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob

From 72393ec0af80a433b887047612b730f9e2d1cc50 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Thu, 30 Dec 2021 17:03:49 +0100
Subject: [PATCH 07/16] Update strings.xml

---
 app/src/main/res/values/strings.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f3fbb2e..34f5ae9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -56,7 +56,7 @@
         messages via PUT or POST and you\'ll receive notifications on your phone.
     </string>
     <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
-    <string name="main_unified_push_toast">Subscription is managed by %1$s via UnifiedPush</string>
+    <string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string>
 
     <!-- Add dialog -->
     <string name="add_dialog_title">Subscribe to topic</string>

From 2bc87013d59cb302b34c16f2a619713962c0248a Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 31 Dec 2021 01:34:25 +0100
Subject: [PATCH 08/16] Preferences dialog

---
 app/build.gradle                              |  1 +
 app/src/main/AndroidManifest.xml              | 40 ++++++---
 .../java/io/heckel/ntfy/data/Repository.kt    | 33 +++++--
 .../java/io/heckel/ntfy/ui/DetailActivity.kt  |  1 -
 .../java/io/heckel/ntfy/ui/MainActivity.kt    |  4 +
 .../io/heckel/ntfy/ui/SettingsActivity.kt     | 88 +++++++++++++++++++
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    | 12 +--
 app/src/main/res/layout/activity_settings.xml |  9 ++
 .../main/res/layout/fragment_main_item.xml    |  7 +-
 .../preference_category_material_edited.xml   | 68 ++++++++++++++
 .../main/res/menu/menu_main_action_bar.xml    |  1 +
 app/src/main/res/values/strings.xml           | 38 ++++++--
 app/src/main/res/xml/main_preferences.xml     | 21 +++++
 13 files changed, 291 insertions(+), 32 deletions(-)
 create mode 100644 app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
 create mode 100644 app/src/main/res/layout/activity_settings.xml
 create mode 100644 app/src/main/res/layout/preference_category_material_edited.xml
 create mode 100644 app/src/main/res/xml/main_preferences.xml

diff --git a/app/build.gradle b/app/build.gradle
index 24d40a4..d9138fa 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -67,6 +67,7 @@ dependencies {
 
     // WorkManager
     implementation "androidx.work:work-runtime-ktx:2.6.0"
+    implementation 'androidx.preference:preference:1.1.1'
 
     // Room (SQLite)
     def roomVersion = "2.3.0"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2fbf176..bffc978 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,7 +12,7 @@
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
     <uses-permission android:name="android.permission.WAKE_LOCK"/>
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
-    <uses-permission android:name="android.permission.VIBRATE" />
+    <uses-permission android:name="android.permission.VIBRATE"/>
 
     <application
             android:name=".app.Application"
@@ -23,6 +23,7 @@
             android:supportsRtl="true"
             android:theme="@style/AppTheme"
             android:usesCleartextTraffic="true">
+
         <!-- Main activity -->
         <activity
                 android:name=".ui.MainActivity"
@@ -43,32 +44,51 @@
                     android:value=".ui.MainActivity"/>
         </activity>
 
+        <!-- Settings activity -->
+        <activity
+                android:name=".ui.SettingsActivity"
+                android:parentActivityName=".ui.MainActivity">
+            <meta-data
+                    android:name="android.support.PARENT_ACTIVITY"
+                    android:value=".ui.MainActivity"/>
+        </activity>
+
         <!-- Subscriber foreground service for hosts other than ntfy.sh -->
         <service android:name=".service.SubscriberService"/>
 
         <!-- Subscriber service restart on reboot -->
-        <receiver android:name=".service.SubscriberService$BootStartReceiver" android:enabled="true">
+        <receiver
+                android:name=".service.SubscriberService$BootStartReceiver"
+                android:enabled="true">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED"/>
             </intent-filter>
         </receiver>
 
         <!-- Subscriber service restart on destruction -->
-        <receiver android:name=".service.SubscriberService$AutoRestartReceiver" android:enabled="true"
-                  android:exported="false"/>
+        <receiver
+                android:name=".service.SubscriberService$AutoRestartReceiver"
+                android:enabled="true"
+                android:exported="false"/>
 
         <!-- Broadcast receiver to send messages via intents -->
-        <receiver android:name=".msg.BroadcastService$BroadcastReceiver" android:enabled="true" android:exported="true">
+        <receiver
+                android:name=".msg.BroadcastService$BroadcastReceiver"
+                android:enabled="true"
+                android:exported="true">
             <intent-filter>
                 <action android:name="io.heckel.ntfy.SEND_MESSAGE"/>
             </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">
+        <!-- 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" />
+                <action android:name="org.unifiedpush.android.distributor.REGISTER"/>
+                <action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
             </intent-filter>
         </receiver>
 
@@ -80,7 +100,6 @@
                 <action android:name="com.google.firebase.MESSAGING_EVENT"/>
             </intent-filter>
         </service>
-
         <meta-data
                 android:name="firebase_analytics_collection_enabled"
                 android:value="false"/>
@@ -88,5 +107,4 @@
                 android:name="com.google.firebase.messaging.default_notification_icon"
                 android:resource="@drawable/ic_notification"/>
     </application>
-
 </manifest>
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 1d1a2cc..59b0112 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
 import android.util.Log
 import androidx.annotation.WorkerThread
 import androidx.lifecycle.*
+import androidx.preference.PreferenceManager
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.atomic.AtomicLong
 
@@ -142,12 +143,32 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
             .apply()
     }
 
-    private suspend fun isMuted(subscriptionId: Long): Boolean {
-        if (isGlobalMuted()) {
-            return true
+
+    fun getUnifiedPushEnabled(): Boolean {
+        return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default!
+    }
+
+    fun setUnifiedPushEnabled(enabled: Boolean) {
+        sharedPrefs.edit()
+            .putBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, enabled)
+            .apply()
+    }
+
+    fun getUnifiedPushBaseUrl(): String? {
+        return sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null)
+    }
+
+    fun setUnifiedPushBaseUrl(baseUrl: String) {
+        if (baseUrl == "") {
+            sharedPrefs
+                .edit()
+                .remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL)
+                .apply()
+        } else {
+            sharedPrefs.edit()
+                .putString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, baseUrl)
+                .apply()
         }
-        val s = getSubscription(subscriptionId) ?: return true
-        return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
     }
 
     fun isGlobalMuted(): Boolean {
@@ -242,6 +263,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
         const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
         const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
+        const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
+        const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"
 
         private const val TAG = "NtfyRepository"
         private var instance: Repository? = null
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
index 4477a1b..4113a46 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
@@ -420,7 +420,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
                 val formattedDate = formatDateShort(subscriptionMutedUntil)
                 notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
             }
-
         }
     }
 
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 9829aa4..bb971f3 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -232,6 +232,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
                 onNotificationSettingsClick(enable = true)
                 true
             }
+            R.id.main_menu_settings -> {
+                startActivity(Intent(this, SettingsActivity::class.java))
+                true
+            }
             R.id.main_menu_source -> {
                 startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
                 true
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
new file mode 100644
index 0000000..ae87e6a
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -0,0 +1,88 @@
+package io.heckel.ntfy.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import androidx.preference.*
+import io.heckel.ntfy.BuildConfig
+import io.heckel.ntfy.R
+import io.heckel.ntfy.app.Application
+import io.heckel.ntfy.data.Repository
+
+class SettingsActivity : AppCompatActivity() {
+    private val repository by lazy { (application as Application).repository }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_settings)
+
+        Log.d(MainActivity.TAG, "Create $this")
+
+        if (savedInstanceState == null) {
+            supportFragmentManager
+                .beginTransaction()
+                .replace(R.id.settings_layout, SettingsFragment(repository))
+                .commit()
+        }
+
+        // Action bar
+        title = getString(R.string.settings_title)
+
+        // Show 'Back' button
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+    }
+
+    class SettingsFragment(val repository: Repository) : PreferenceFragmentCompat() {
+        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+            setPreferencesFromResource(R.xml.main_preferences, rootKey)
+
+            // UnifiedPush Enabled
+            val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return
+            val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
+            upEnabled?.isChecked = repository.getUnifiedPushEnabled()
+            upEnabled?.preferenceDataStore = object : PreferenceDataStore() {
+                override fun putBoolean(key: String?, value: Boolean) {
+                    repository.setUnifiedPushEnabled(value)
+                }
+                override fun getBoolean(key: String?, defValue: Boolean): Boolean {
+                    return repository.getUnifiedPushEnabled()
+                }
+            }
+            upEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
+                if (pref.isChecked) {
+                    getString(R.string.settings_unified_push_enabled_summary_on)
+                } else {
+                    getString(R.string.settings_unified_push_enabled_summary_off)
+                }
+            }
+
+            // UnifiedPush Base URL
+            val appBaseUrl = context?.getString(R.string.app_base_url) ?: return
+            val upBaseUrlPrefId = context?.getString(R.string.pref_unified_push_base_url) ?: return
+            val upBaseUrl: EditTextPreference? = findPreference(upBaseUrlPrefId)
+            upBaseUrl?.text = repository.getUnifiedPushBaseUrl() ?: ""
+            upBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
+                override fun putString(key: String, value: String?) {
+                    val baseUrl = value ?: return
+                    repository.setUnifiedPushBaseUrl(baseUrl)
+                }
+                override fun getString(key: String, defValue: String?): String? {
+                    return repository.getUnifiedPushBaseUrl()
+                }
+            }
+            upBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
+                if (TextUtils.isEmpty(pref.text)) {
+                    getString(R.string.settings_unified_push_base_url_default_summary, appBaseUrl)
+                } else {
+                    pref.text
+                }
+            }
+
+            // Version
+            val versionPrefId = context?.getString(R.string.pref_version) ?: return
+            val versionPref: Preference? = findPreference(versionPrefId)
+            versionPref?.summary = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
+        }
+    }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 345ca31..8e07ee0 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -3,9 +3,9 @@ package io.heckel.ntfy.up
 import android.content.Context
 import android.content.Intent
 import android.util.Log
+import androidx.preference.PreferenceManager
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
-import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.service.SubscriberServiceManager
 import io.heckel.ntfy.util.randomString
@@ -34,8 +34,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
         val repository = app.repository
         val distributor = Distributor(app)
         Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
-        if (appId.isBlank()) {
-            Log.w(TAG, "Refusing registration: empty application")
+        if (!repository.getUnifiedPushEnabled() || appId.isBlank()) {
+            Log.w(TAG, "Refusing registration: UnifiedPush disabled or empty application")
             distributor.sendRegistrationRefused(appId, connectorToken)
             return
         }
@@ -54,8 +54,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
             }
 
             // Add subscription
-            val baseUrl = context.getString(R.string.app_base_url) // FIXME
-            val topic = UP_PREFIX + randomString(TOPIC_LENGTH)
+            val baseUrl = repository.getUnifiedPushBaseUrl() ?: context.getString(R.string.app_base_url)
+            val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH)
             val endpoint = topicUrlUp(baseUrl, topic)
             val subscription = Subscription(
                 id = Random.nextLong(),
@@ -105,6 +105,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
     companion object {
         private const val TAG = "NtfyUpBroadcastRecv"
         private const val UP_PREFIX = "up"
-        private const val TOPIC_LENGTH = 16
+        private const val TOPIC_RANDOM_ID_LENGTH = 12
     }
 }
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..f02c645
--- /dev/null
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,9 @@
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+    <FrameLayout
+            android:id="@+id/settings_layout"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"/>
+</LinearLayout>
diff --git a/app/src/main/res/layout/fragment_main_item.xml b/app/src/main/res/layout/fragment_main_item.xml
index d46449c..050083d 100644
--- a/app/src/main/res/layout/fragment_main_item.xml
+++ b/app/src/main/res/layout/fragment_main_item.xml
@@ -24,12 +24,13 @@
             android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
             app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
     <TextView
-            android:text="89 notifications"
-            android:layout_width="wrap_content"
+            android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
+            android:layout_width="0dp"
             android:layout_height="wrap_content" android:id="@+id/main_item_status"
             app:layout_constraintStart_toStartOf="@+id/main_item_text"
             app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
-            android:layout_marginBottom="10dp"/>
+            android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
+            app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
     <ImageView
             android:layout_width="20dp"
             android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"
diff --git a/app/src/main/res/layout/preference_category_material_edited.xml b/app/src/main/res/layout/preference_category_material_edited.xml
new file mode 100644
index 0000000..f0a481a
--- /dev/null
+++ b/app/src/main/res/layout/preference_category_material_edited.xml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  This is a slightly edited copy of the original Android project layout
+  to make wrapping the summary line work.
+
+  ~ Copyright (C) 2015 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:paddingLeft="?android:attr/listPreferredItemPaddingLeft"
+    android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+    android:paddingRight="?android:attr/listPreferredItemPaddingRight"
+    android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+    android:background="?android:attr/selectableItemBackground"
+    android:baselineAligned="false"
+    android:layout_marginTop="16dp"
+    android:gravity="center_vertical">
+
+    <include layout="@layout/image_frame"/>
+
+    <RelativeLayout
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp">
+
+        <TextView
+            android:id="@android:id/title"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="start"
+            android:textAlignment="viewStart"
+            style="@style/PreferenceCategoryTitleTextStyle"/>
+
+        <!-- EDITED singleLine -->
+        <TextView
+            android:id="@android:id/summary"
+            android:ellipsize="end"
+            android:singleLine="false"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_below="@android:id/title"
+            android:layout_alignLeft="@android:id/title"
+            android:layout_alignStart="@android:id/title"
+            android:layout_gravity="start"
+            android:textAlignment="viewStart"
+            android:textColor="?android:attr/textColorSecondary"
+            android:maxLines="10"
+            style="@style/PreferenceSummaryTextStyle"/>
+
+    </RelativeLayout>
+</LinearLayout>
diff --git a/app/src/main/res/menu/menu_main_action_bar.xml b/app/src/main/res/menu/menu_main_action_bar.xml
index 89c763b..bd213aa 100644
--- a/app/src/main/res/menu/menu_main_action_bar.xml
+++ b/app/src/main/res/menu/menu_main_action_bar.xml
@@ -5,6 +5,7 @@
           app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/>
     <item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
           app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
+    <item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/>
     <item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/>
     <item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
 </menu>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 34f5ae9..9a8e887 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -16,7 +16,8 @@
     <string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string>
     <string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string>
     <string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string>
-    <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics</string>
+    <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics
+    </string>
     <string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string>
     <string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string>
 
@@ -30,6 +31,7 @@
     <string name="main_menu_notifications_enabled">Notifications enabled</string>
     <string name="main_menu_notifications_disabled_forever">Notifications disabled</string>
     <string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
+    <string name="main_menu_settings_title">Settings</string>
     <string name="main_menu_source_title">Report a bug</string>
     <string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
     <string name="main_menu_website_title">Visit ntfy.sh</string>
@@ -55,7 +57,8 @@
         Click the button below to create or subscribe to a topic. After that, you can send
         messages via PUT or POST and you\'ll receive notifications on your phone.
     </string>
-    <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
+    <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
+    </string>
     <string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string>
 
     <!-- Add dialog -->
@@ -81,10 +84,13 @@
     <!-- Detail activity -->
     <string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string>
     <string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string>
-    <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string>
+    <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.
+    </string>
     <string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
-    <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
-    <string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?</string>
+    <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
+    </string>
+    <string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?
+    </string>
     <string name="detail_clear_dialog_permanently_delete">Permanently delete</string>
     <string name="detail_clear_dialog_cancel">Cancel</string>
     <string name="detail_delete_dialog_message">
@@ -94,7 +100,9 @@
     <string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
     <string name="detail_delete_dialog_cancel">Cancel</string>
     <string name="detail_test_title">Test: You can set a title if you like</string>
-    <string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d. If you send another one, it may look different.</string>
+    <string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d.
+        If you send another one, it may look different.
+    </string>
     <string name="detail_test_message_error">Could not send test message: %1$s</string>
     <string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
     <string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
@@ -136,4 +144,22 @@
     <string name="notification_dialog_8h">8 hours</string>
     <string name="notification_dialog_tomorrow">Until tomorrow</string>
     <string name="notification_dialog_forever">Forever</string>
+
+    <!-- Settings -->
+    <string name="settings_title">Settings</string>
+    <string name="settings_unified_push_header">UnifiedPush</string>
+    <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
+    <string name="settings_unified_push_enabled_title">Enabled</string>
+    <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
+    <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
+    <string name="settings_unified_push_base_url_title">Server URL</string>
+    <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string>
+    <string name="settings_about_header">About</string>
+    <string name="settings_about_version">Version</string>
+    <string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
+
+    <!-- Preferences IDs -->
+    <string name="pref_unified_push_enabled">UnifiedPushEnabled</string>
+    <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string>
+    <string name="pref_version">Version</string>
 </resources>
diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml
new file mode 100644
index 0000000..30a196c
--- /dev/null
+++ b/app/src/main/res/xml/main_preferences.xml
@@ -0,0 +1,21 @@
+<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
+                  app:title="@string/settings_title">
+    <PreferenceCategory
+            app:title="@string/settings_unified_push_header"
+            app:summary="@string/settings_unified_push_header_summary"
+            app:layout="@layout/preference_category_material_edited">
+        <SwitchPreference
+                app:key="@string/pref_unified_push_enabled"
+                app:title="@string/settings_unified_push_enabled_title"
+                app:enabled="true"/>
+        <EditTextPreference
+                app:key="@string/pref_unified_push_base_url"
+                app:title="@string/settings_unified_push_base_url_title"
+                app:dependency="@string/pref_unified_push_enabled"/>
+    </PreferenceCategory>
+    <PreferenceCategory app:title="@string/settings_about_header">
+        <Preference
+                app:key="@string/pref_version"
+                app:title="@string/settings_about_version"/>
+    </PreferenceCategory>
+</PreferenceScreen>

From bec263d1c818191ed106baf11e0cfc80f68481b7 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 31 Dec 2021 02:00:08 +0100
Subject: [PATCH 09/16] Tiny changes

---
 .../java/io/heckel/ntfy/msg/NotificationDispatcher.kt     | 8 ++++----
 app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt     | 4 +---
 app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt       | 4 +---
 3 files changed, 6 insertions(+), 10 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index 18eb5b1..57cab2d 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -7,6 +7,10 @@ import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.up.Distributor
 import io.heckel.ntfy.util.safeLet
 
+/**
+ * The notification dispatcher figures out what to do with a notification.
+ * It may display a notification, send out a broadcast, or forward via UnifiedPush.
+ */
 class NotificationDispatcher(val context: Context, val repository: Repository) {
     private val notifier = NotificationService(context)
     private val broadcaster = BroadcastService(context)
@@ -48,8 +52,4 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
         }
         return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
     }
-
-    companion object {
-        private const val TAG = "NtfyNotificationDispatcher"
-    }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
index 4113a46..599d5fb 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
@@ -42,7 +42,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
     private val repository by lazy { (application as Application).repository }
     private val api = ApiService()
     private val messenger = FirebaseMessenger()
-    private var serviceManager: SubscriberServiceManager? = null // Context-dependent
     private var notifier: NotificationService? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
@@ -69,7 +68,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
         Log.d(MainActivity.TAG, "Create $this")
 
         // Dependencies that depend on Context
-        serviceManager = SubscriberServiceManager(this)
         notifier = NotificationService(this)
         appBaseUrl = getString(R.string.app_base_url)
 
@@ -146,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
 
         // React to changes in fast delivery setting
         repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
-            serviceManager?.refresh()
+            SubscriberServiceManager.refresh(this)
         }
 
         // Mark this subscription as "open" so we don't receive notifications for it
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index bb971f3..8297a95 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -54,7 +54,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
     private var actionMode: ActionMode? = null
     private var workManager: WorkManager? = null // Context-dependent
     private var dispatcher: NotificationDispatcher? = null // Context-dependent
-    private var serviceManager: SubscriberServiceManager? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -66,7 +65,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         // Dependencies that depend on Context
         workManager = WorkManager.getInstance(this)
         dispatcher = NotificationDispatcher(this, repository)
-        serviceManager = SubscriberServiceManager(this)
         appBaseUrl = getString(R.string.app_base_url)
 
         // Action bar
@@ -107,7 +105,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
         // React to changes in instant delivery setting
         viewModel.listIdsWithInstantStatus().observe(this) {
-            serviceManager?.refresh()
+            SubscriberServiceManager.refresh(this)
         }
 
         // Create notification channels right away, so we can configure them immediately after installing the app

From 496bdcd2856929ad2f3976f49e42b95af8dac9b0 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 31 Dec 2021 15:30:49 +0100
Subject: [PATCH 10/16] Comments

---
 .../heckel/ntfy/msg/NotificationDispatcher.kt |  4 ++--
 .../ntfy/service/SubscriberServiceManager.kt  |  9 ++++++++
 .../io/heckel/ntfy/ui/SettingsActivity.kt     | 22 ++++++++++++++++++-
 .../io/heckel/ntfy/up/BroadcastReceiver.kt    |  5 ++++-
 .../java/io/heckel/ntfy/up/Distributor.kt     |  4 ++++
 app/src/main/java/io/heckel/ntfy/util/Util.kt |  1 +
 app/src/main/res/values/strings.xml           |  1 +
 7 files changed, 42 insertions(+), 4 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index 57cab2d..f2c13d0 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -23,8 +23,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
     fun dispatch(subscription: Subscription, notification: Notification) {
         val muted = checkMuted(subscription)
         val notify = checkNotify(subscription, notification, muted)
-        val broadcast = subscription.upAppId == null
-        val distribute = subscription.upAppId != null
+        val broadcast = subscription.upAppId == null // Never broadcast for UnifiedPush
+        val distribute = subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
         if (notify) {
             notifier.send(subscription, notification)
         }
diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
index e3d6174..a4ecfbb 100644
--- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
+++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
@@ -11,6 +11,11 @@ import io.heckel.ntfy.up.BroadcastReceiver
 /**
  * This class only manages the SubscriberService, i.e. it starts or stops it.
  * It's used in multiple activities.
+ *
+ * We are starting the service via a worker and not directly because since Android 7
+ * (but officially since Lollipop!), any process called by a BroadcastReceiver
+ * (only manifest-declared receiver) is run at low priority and hence eventually
+ * killed by Android.
  */
 class SubscriberServiceManager(private val context: Context) {
     fun refresh() {
@@ -20,6 +25,10 @@ class SubscriberServiceManager(private val context: Context) {
         workManager.enqueue(startServiceRequest)
     }
 
+    /**
+     * Starts or stops the foreground service by figuring out how many instant delivery subscriptions
+     * exist. If there's > 0, then we need a foreground service.
+     */
     class RefreshWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
         override fun doWork(): Result {
             if (context.applicationContext !is Application) {
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
index ae87e6a..7a3992d 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -1,10 +1,15 @@
 package io.heckel.ntfy.ui
 
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
 import android.os.Bundle
 import android.text.TextUtils
 import android.util.Log
+import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
 import androidx.preference.*
+import androidx.preference.Preference.OnPreferenceClickListener
 import io.heckel.ntfy.BuildConfig
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
@@ -37,6 +42,10 @@ class SettingsActivity : AppCompatActivity() {
         override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
             setPreferencesFromResource(R.xml.main_preferences, rootKey)
 
+            // Important note: We do not use the default shared prefs to store settings. Every
+            // preferenceDataStore is overridden to use the repository. This is convenient, because
+            // everybody has access to the repository.
+
             // UnifiedPush Enabled
             val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return
             val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
@@ -82,7 +91,18 @@ class SettingsActivity : AppCompatActivity() {
             // Version
             val versionPrefId = context?.getString(R.string.pref_version) ?: return
             val versionPref: Preference? = findPreference(versionPrefId)
-            versionPref?.summary = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
+            val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
+            versionPref?.summary = version
+            versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
+                val context = context ?: return@OnPreferenceClickListener false
+                val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+                val clip = ClipData.newPlainText("app version", version)
+                clipboard.setPrimaryClip(clip)
+                Toast
+                    .makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG)
+                    .show()
+                true
+            }
         }
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
index 8e07ee0..2a7d64c 100644
--- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -16,6 +16,10 @@ import kotlinx.coroutines.launch
 import java.util.*
 import kotlin.random.Random
 
+/**
+ * This is the UnifiedPush broadcast receiver to handle the distributor actions REGISTER and UNREGISTER.
+ * See https://unifiedpush.org/spec/android/ for details.
+ */
 class BroadcastReceiver : android.content.BroadcastReceiver() {
     override fun onReceive(context: Context?, intent: Intent?) {
         if (context == null || intent == null) {
@@ -69,7 +73,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
                 newCount = 0,
                 lastActive = Date().time/1000
             )
-
             Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
             repository.addSubscription(subscription)
             distributor.sendEndpoint(appId, connectorToken, endpoint)
diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
index a4c1ad9..38273a9 100644
--- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
@@ -4,6 +4,10 @@ import android.content.Context
 import android.content.Intent
 import android.util.Log
 
+/**
+ * This is the UnifiedPush distributor, an amalgamation of messages to be sent as part of the spec.
+ * 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")
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index 1b88747..23c3747 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -104,6 +104,7 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
     statusBarColorAnimation.start()
 }
 
+// Generates a (cryptographically secure) random string of a certain length
 fun randomString(len: Int): String {
     val random = SecureRandom()
     val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9a8e887..d209226 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -157,6 +157,7 @@
     <string name="settings_about_header">About</string>
     <string name="settings_about_version">Version</string>
     <string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
+    <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string>
 
     <!-- Preferences IDs -->
     <string name="pref_unified_push_enabled">UnifiedPushEnabled</string>

From f527ee734394f420490d1148dc92522af7f205df Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 00:12:36 +0100
Subject: [PATCH 11/16] Migration fix

---
 app/src/main/java/io/heckel/ntfy/data/Database.kt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index e0187ce..94a56b9 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -108,7 +108,7 @@ abstract class Database : RoomDatabase() {
             }
         }
 
-        private val MIGRATION_4_5 = object : Migration(3, 4) {
+        private val MIGRATION_4_5 = object : Migration(4, 5) {
             override fun migrate(db: SupportSQLiteDatabase) {
                 db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
                 db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")

From 9cc6ffc32ee95f36857fe1f9ec13325fa26ffa6a Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 00:21:59 +0100
Subject: [PATCH 12/16] Add index during migration

---
 app/src/main/java/io/heckel/ntfy/data/Database.kt | 1 +
 1 file changed, 1 insertion(+)

diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 94a56b9..8fc2325 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -112,6 +112,7 @@ abstract class Database : RoomDatabase() {
             override fun migrate(db: SupportSQLiteDatabase) {
                 db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
                 db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
+                db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
             }
         }
     }

From 1c6dd84543246074530c28545aa68f53f0f4e56a Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 00:25:50 +0100
Subject: [PATCH 13/16] Bump version

---
 app/build.gradle | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index d9138fa..a529e64 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,8 +12,8 @@ android {
         minSdkVersion 21
         targetSdkVersion 30
 
-        versionCode 12
-        versionName "1.4.2"
+        versionCode 13
+        versionName "1.5.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
 

From 1ce42048b59622e7d5cff8abc38e5ee70efcd4b1 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 13:42:00 +0100
Subject: [PATCH 14/16] MutedUntil setting in Settings dialog

---
 .../java/io/heckel/ntfy/ui/MainActivity.kt    |  2 +-
 .../io/heckel/ntfy/ui/NotificationFragment.kt | 10 +++--
 .../io/heckel/ntfy/ui/SettingsActivity.kt     | 38 ++++++++++++++++++-
 app/src/main/res/values/strings.xml           | 14 +++++--
 app/src/main/res/xml/main_preferences.xml     | 11 +++++-
 5 files changed, 64 insertions(+), 11 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 8297a95..b571331 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -169,7 +169,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
     private fun startNotificationMutedChecker() {
         lifecycleScope.launch(Dispatchers.IO) {
-            delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
+            delay(5000) // Just to be sure we've initialized all the things, we wait a bit ...
             while (isActive) {
                 Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp")
 
diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
index a09dd8f..a0f3bb2 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
@@ -4,6 +4,7 @@ import android.app.AlertDialog
 import android.app.Dialog
 import android.content.Context
 import android.os.Bundle
+import android.util.Log
 import android.widget.RadioButton
 import androidx.fragment.app.DialogFragment
 import androidx.lifecycle.lifecycleScope
@@ -16,8 +17,9 @@ import kotlinx.coroutines.launch
 import java.util.*
 
 class NotificationFragment : DialogFragment() {
+    var settingsListener: NotificationSettingsListener? = null
+
     private lateinit var repository: Repository
-    private lateinit var settingsListener: NotificationSettingsListener
     private lateinit var muteFor30minButton: RadioButton
     private lateinit var muteFor1hButton: RadioButton
     private lateinit var muteFor2hButton: RadioButton
@@ -31,7 +33,9 @@ class NotificationFragment : DialogFragment() {
 
     override fun onAttach(context: Context) {
         super.onAttach(context)
-        settingsListener = activity as NotificationSettingsListener
+        if (settingsListener == null) {
+            settingsListener = activity as NotificationSettingsListener
+        }
     }
 
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@@ -85,7 +89,7 @@ class NotificationFragment : DialogFragment() {
     private fun onClick(mutedUntilTimestamp: Long) {
         lifecycleScope.launch(Dispatchers.Main) {
             delay(150) // Another hack: Let the animation finish before dismissing the window
-            settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp)
+            settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp)
             dismiss()
         }
     }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
index 7a3992d..c1a709d 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -8,12 +8,14 @@ import android.text.TextUtils
 import android.util.Log
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentManager
 import androidx.preference.*
 import androidx.preference.Preference.OnPreferenceClickListener
 import io.heckel.ntfy.BuildConfig
 import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Repository
+import io.heckel.ntfy.util.formatDateShort
 
 class SettingsActivity : AppCompatActivity() {
     private val repository by lazy { (application as Application).repository }
@@ -27,7 +29,7 @@ class SettingsActivity : AppCompatActivity() {
         if (savedInstanceState == null) {
             supportFragmentManager
                 .beginTransaction()
-                .replace(R.id.settings_layout, SettingsFragment(repository))
+                .replace(R.id.settings_layout, SettingsFragment(repository, supportFragmentManager))
                 .commit()
         }
 
@@ -38,7 +40,7 @@ class SettingsActivity : AppCompatActivity() {
         supportActionBar?.setDisplayHomeAsUpEnabled(true)
     }
 
-    class SettingsFragment(val repository: Repository) : PreferenceFragmentCompat() {
+    class SettingsFragment(val repository: Repository, private val supportFragmentManager: FragmentManager) : PreferenceFragmentCompat() {
         override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
             setPreferencesFromResource(R.xml.main_preferences, rootKey)
 
@@ -46,6 +48,38 @@ class SettingsActivity : AppCompatActivity() {
             // preferenceDataStore is overridden to use the repository. This is convenient, because
             // everybody has access to the repository.
 
+            // Notifications muted until (global)
+            val mutedUntilPrefId = context?.getString(R.string.pref_notifications_muted_until) ?: return
+            val mutedUntilSummary = { s: Long ->
+                when (s) {
+                    0L -> getString(R.string.settings_notifications_muted_until_enabled)
+                    1L -> getString(R.string.settings_notifications_muted_until_disabled_forever)
+                    else -> {
+                        val formattedDate = formatDateShort(s)
+                        getString(R.string.settings_notifications_muted_until_disabled_until, formattedDate)
+                    }
+                }
+            }
+            val mutedUntil: Preference? = findPreference(mutedUntilPrefId)
+            mutedUntil?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
+            mutedUntil?.summary = mutedUntilSummary(repository.getGlobalMutedUntil())
+            mutedUntil?.onPreferenceClickListener = OnPreferenceClickListener {
+                if (repository.getGlobalMutedUntil() > 0) {
+                    repository.setGlobalMutedUntil(0)
+                    mutedUntil?.summary = mutedUntilSummary(0)
+                } else {
+                    val notificationFragment = NotificationFragment()
+                    notificationFragment.settingsListener = object : NotificationFragment.NotificationSettingsListener {
+                        override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
+                            repository.setGlobalMutedUntil(mutedUntilTimestamp)
+                            mutedUntil?.summary = mutedUntilSummary(mutedUntilTimestamp)
+                        }
+                    }
+                    notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
+                }
+                true
+            }
+
             // UnifiedPush Enabled
             val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return
             val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d209226..4d97b73 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -16,8 +16,7 @@
     <string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string>
     <string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string>
     <string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string>
-    <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics
-    </string>
+    <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics</string>
     <string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string>
     <string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string>
 
@@ -147,19 +146,26 @@
 
     <!-- Settings -->
     <string name="settings_title">Settings</string>
+    <string name="settings_notifications_header">Notifications</string>
+    <string name="settings_notifications_header_summary">General settings for all subscribed topics</string>
+    <string name="settings_notifications_muted_until_title">Pause notifications</string>
+    <string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string>
+    <string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string>
+    <string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string>
     <string name="settings_unified_push_header">UnifiedPush</string>
     <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
-    <string name="settings_unified_push_enabled_title">Enabled</string>
+    <string name="settings_unified_push_enabled_title">Enable distributor</string>
     <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
     <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
     <string name="settings_unified_push_base_url_title">Server URL</string>
     <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string>
     <string name="settings_about_header">About</string>
-    <string name="settings_about_version">Version</string>
+    <string name="settings_about_version_title">Version</string>
     <string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
     <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string>
 
     <!-- Preferences IDs -->
+    <string name="pref_notifications_muted_until">MutedUntil</string>
     <string name="pref_unified_push_enabled">UnifiedPushEnabled</string>
     <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string>
     <string name="pref_version">Version</string>
diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml
index 30a196c..c404af4 100644
--- a/app/src/main/res/xml/main_preferences.xml
+++ b/app/src/main/res/xml/main_preferences.xml
@@ -1,5 +1,14 @@
 <PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
+                  xmlns:android="http://schemas.android.com/apk/res/android"
                   app:title="@string/settings_title">
+    <PreferenceCategory
+            app:title="@string/settings_notifications_header"
+            app:summary="@string/settings_notifications_header_summary"
+            app:layout="@layout/preference_category_material_edited">
+        <Preference
+                app:key="@string/pref_notifications_muted_until"
+                app:title="@string/settings_notifications_muted_until_title"/>
+    </PreferenceCategory>
     <PreferenceCategory
             app:title="@string/settings_unified_push_header"
             app:summary="@string/settings_unified_push_header_summary"
@@ -16,6 +25,6 @@
     <PreferenceCategory app:title="@string/settings_about_header">
         <Preference
                 app:key="@string/pref_version"
-                app:title="@string/settings_about_version"/>
+                app:title="@string/settings_about_version_title"/>
     </PreferenceCategory>
 </PreferenceScreen>

From d10344549f4f5e8aa14deabc4070666cbd8a336e Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 16:56:18 +0100
Subject: [PATCH 15/16] Add min priority and broadcast enabled switch, fix #57

---
 .../java/io/heckel/ntfy/data/Repository.kt    | 29 +++++++++-
 .../io/heckel/ntfy/msg/BroadcastService.kt    | 53 +++++++++++-------
 .../heckel/ntfy/msg/NotificationDispatcher.kt | 27 +++++++--
 .../java/io/heckel/ntfy/ui/MainActivity.kt    |  3 +-
 .../java/io/heckel/ntfy/ui/MainAdapter.kt     | 14 +++--
 .../io/heckel/ntfy/ui/SettingsActivity.kt     | 56 +++++++++++++++++--
 app/src/main/java/io/heckel/ntfy/util/Util.kt | 11 ++++
 app/src/main/res/values/strings.xml           | 28 +++++++---
 app/src/main/res/values/values.xml            | 17 ++++++
 app/src/main/res/xml/main_preferences.xml     | 23 ++++++--
 10 files changed, 210 insertions(+), 51 deletions(-)
 create mode 100644 app/src/main/res/values/values.xml

diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 59b0112..0907ca4 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -143,9 +143,34 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
             .apply()
     }
 
+    fun setMinPriority(minPriority: Int) {
+        if (minPriority <= 1) {
+            sharedPrefs.edit()
+                .remove(SHARED_PREFS_MIN_PRIORITY)
+                .apply()
+        } else {
+            sharedPrefs.edit()
+                .putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
+                .apply()
+        }
+    }
+
+    fun getMinPriority(): Int {
+        return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, 1) // 1/low means all priorities
+    }
+
+    fun getBroadcastEnabled(): Boolean {
+        return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default
+    }
+
+    fun setBroadcastEnabled(enabled: Boolean) {
+        sharedPrefs.edit()
+            .putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
+            .apply()
+    }
 
     fun getUnifiedPushEnabled(): Boolean {
-        return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default!
+        return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default
     }
 
     fun setUnifiedPushEnabled(enabled: Boolean) {
@@ -263,6 +288,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
         const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
         const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
+        const val SHARED_PREFS_MIN_PRIORITY = "MinPriority"
+        const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
         const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
         const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"
 
diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
index bc32d28..9d4e43f 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt
@@ -13,8 +13,8 @@ import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
 
 /**
- * The broadcast service is responsible for sending and receiving broadcasted intents
- * in order to facilitate taks app integrations.
+ * The broadcast service is responsible for sending and receiving broadcast intents
+ * in order to facilitate tasks app integrations.
  */
 class BroadcastService(private val ctx: Context) {
     fun send(subscription: Subscription, notification: Notification, muted: Boolean) {
@@ -36,6 +36,10 @@ class BroadcastService(private val ctx: Context) {
         ctx.sendBroadcast(intent)
     }
 
+    /**
+     * This receiver is triggered when the SEND_MESSAGE intent is received.
+     * See AndroidManifest.xml for details.
+     */
     class BroadcastReceiver : android.content.BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
             Log.d(TAG, "Broadcast received: $intent")
@@ -46,24 +50,20 @@ class BroadcastService(private val ctx: Context) {
 
         private fun send(ctx: Context, intent: Intent) {
             val api = ApiService()
-            val baseUrl = intent.getStringExtra("base_url") ?: ctx.getString(R.string.app_base_url)
-            val topic = intent.getStringExtra("topic") ?: return
-            val message = intent.getStringExtra("message") ?: return
-            val title = intent.getStringExtra("title") ?: ""
-            val tags = intent.getStringExtra("tags") ?: ""
-            val priority = if (intent.getStringExtra("priority") != null) {
-                when (intent.getStringExtra("priority")) {
-                    "min", "1" -> 1
-                    "low", "2" -> 2
-                    "default", "3" -> 3
-                    "high", "4" -> 4
-                    "urgent", "max", "5" -> 5
-                    else -> 0
-                }
-            } else {
-                intent.getIntExtra("priority", 0)
+            val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
+            val topic = getStringExtra(intent, "topic") ?: return
+            val message = getStringExtra(intent, "message") ?: return
+            val title = getStringExtra(intent, "title") ?: ""
+            val tags = getStringExtra(intent,"tags") ?: ""
+            val priority = when (getStringExtra(intent, "priority")) {
+                "min", "1" -> 1
+                "low", "2" -> 2
+                "default", "3" -> 3
+                "high", "4" -> 4
+                "urgent", "max", "5" -> 5
+                else -> 0
             }
-            val delay = intent.getStringExtra("delay") ?: ""
+            val delay = getStringExtra(intent,"delay") ?: ""
             GlobalScope.launch(Dispatchers.IO) {
                 api.publish(
                     baseUrl = baseUrl,
@@ -76,11 +76,26 @@ class BroadcastService(private val ctx: Context) {
                 )
             }
         }
+
+        /**
+         * Gets an extra as a String value, even if the extra may be an int or a long.
+         */
+        private fun getStringExtra(intent: Intent, name: String): String? {
+            if (intent.getStringExtra(name) != null) {
+                return intent.getStringExtra(name)
+            } else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) {
+                return intent.getIntExtra(name, DOES_NOT_EXIST).toString()
+            } else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) {
+                return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString()
+            }
+            return null
+        }
     }
 
     companion object {
         private const val TAG = "NtfyBroadcastService"
         private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
         private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" // If changed, change in manifest too!
+        private const val DOES_NOT_EXIST = -2586000
     }
 }
diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
index f2c13d0..c4cec51 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -21,10 +21,10 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
     }
 
     fun dispatch(subscription: Subscription, notification: Notification) {
-        val muted = checkMuted(subscription)
-        val notify = checkNotify(subscription, notification, muted)
-        val broadcast = subscription.upAppId == null // Never broadcast for UnifiedPush
-        val distribute = subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
+        val muted = getMuted(subscription)
+        val notify = shouldNotify(subscription, notification, muted)
+        val broadcast = shouldBroadcast(subscription)
+        val distribute = shouldDistribute(subscription)
         if (notify) {
             notifier.send(subscription, notification)
         }
@@ -38,15 +38,30 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
         }
     }
 
-    private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
+    private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
         if (subscription.upAppId != null) {
             return false
         }
+        val priority = if (notification.priority > 0) notification.priority else 3
+        if (priority < repository.getMinPriority()) {
+            return false
+        }
         val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId
         return !detailsVisible && !muted
     }
 
-    private fun checkMuted(subscription: Subscription): Boolean {
+    private fun shouldBroadcast(subscription: Subscription): Boolean {
+        if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions
+            return false
+        }
+        return repository.getBroadcastEnabled()
+    }
+
+    private fun shouldDistribute(subscription: Subscription): Boolean {
+        return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
+    }
+
+    private fun getMuted(subscription: Subscription): Boolean {
         if (repository.isGlobalMuted()) {
             return true
         }
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index b571331..241c2e2 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -87,7 +87,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
 
         mainList = findViewById(R.id.main_subscriptions_list)
-        adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick)
+        adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick)
         mainList.adapter = adapter
 
         viewModel.list().observe(this) {
@@ -261,6 +261,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
         repository.setGlobalMutedUntil(mutedUntilTimestamp)
         showHideNotificationMenuItems()
         runOnUiThread {
+            redrawList() // Update the "muted until" icons
             when (mutedUntilTimestamp) {
                 0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
                 1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
index dfbc016..6a361ed 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
@@ -10,12 +10,13 @@ import androidx.recyclerview.widget.ListAdapter
 import androidx.recyclerview.widget.RecyclerView
 import io.heckel.ntfy.R
 import io.heckel.ntfy.data.ConnectionState
+import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.data.Subscription
 import io.heckel.ntfy.util.topicShortUrl
 import java.text.DateFormat
 import java.util.*
 
-class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
+class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
     ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
     val selected = mutableSetOf<Long>() // Subscription IDs
 
@@ -23,7 +24,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
         val view = LayoutInflater.from(parent.context)
             .inflate(R.layout.fragment_main_item, parent, false)
-        return SubscriptionViewHolder(view, selected, onClick, onLongClick)
+        return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick)
     }
 
     /* Gets current topic and uses it to bind view. */
@@ -41,7 +42,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
     }
 
     /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
-    class SubscriptionViewHolder(itemView: View, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
+    class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
         RecyclerView.ViewHolder(itemView) {
         private var subscription: Subscription? = null
         private val context: Context = itemView.context
@@ -78,11 +79,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
             } else {
                 dateStr
             }
+            val globalMutedUntil = repository.getGlobalMutedUntil()
+            val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && subscription.upAppId == null
+            val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && subscription.upAppId == null
             nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
             statusView.text = statusMessage
             dateView.text = dateText
-            notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
-            notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
+            notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
+            notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
             instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
             if (subscription.upAppId != null || subscription.newCount == 0) {
                 newItemsView.visibility = View.GONE
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
index c1a709d..5a60915 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -16,6 +16,7 @@ import io.heckel.ntfy.R
 import io.heckel.ntfy.app.Application
 import io.heckel.ntfy.data.Repository
 import io.heckel.ntfy.util.formatDateShort
+import io.heckel.ntfy.util.toPriorityString
 
 class SettingsActivity : AppCompatActivity() {
     private val repository by lazy { (application as Application).repository }
@@ -49,7 +50,7 @@ class SettingsActivity : AppCompatActivity() {
             // everybody has access to the repository.
 
             // Notifications muted until (global)
-            val mutedUntilPrefId = context?.getString(R.string.pref_notifications_muted_until) ?: return
+            val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return
             val mutedUntilSummary = { s: Long ->
                 when (s) {
                     0L -> getString(R.string.settings_notifications_muted_until_enabled)
@@ -80,8 +81,53 @@ class SettingsActivity : AppCompatActivity() {
                 true
             }
 
-            // UnifiedPush Enabled
-            val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return
+            // Minimum priority
+            val minPriorityPrefId = context?.getString(R.string.settings_notifications_min_priority_key) ?: return
+            val minPriority: ListPreference? = findPreference(minPriorityPrefId)
+            minPriority?.value = repository.getMinPriority().toString()
+            minPriority?.preferenceDataStore = object : PreferenceDataStore() {
+                override fun putString(key: String?, value: String?) {
+                    val minPriorityValue = value?.toIntOrNull() ?:return
+                    repository.setMinPriority(minPriorityValue)
+                }
+                override fun getString(key: String?, defValue: String?): String {
+                    return repository.getMinPriority().toString()
+                }
+            }
+            minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
+                val minPriorityValue = pref.value.toIntOrNull() ?: 1 // 1/low means all priorities
+                when (minPriorityValue) {
+                    1 -> getString(R.string.settings_notifications_min_priority_summary_any)
+                    5 -> getString(R.string.settings_notifications_min_priority_summary_max)
+                    else -> {
+                        val minPriorityString = toPriorityString(minPriorityValue)
+                        getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString)
+                    }
+                }
+            }
+
+            // Broadcast enabled
+            val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
+            val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
+            broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
+            broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
+                override fun putBoolean(key: String?, value: Boolean) {
+                    repository.setBroadcastEnabled(value)
+                }
+                override fun getBoolean(key: String?, defValue: Boolean): Boolean {
+                    return repository.getBroadcastEnabled()
+                }
+            }
+            broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
+                if (pref.isChecked) {
+                    getString(R.string.settings_advanced_broadcast_summary_enabled)
+                } else {
+                    getString(R.string.settings_advanced_broadcast_summary_disabled)
+                }
+            }
+
+            // UnifiedPush enabled
+            val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return
             val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
             upEnabled?.isChecked = repository.getUnifiedPushEnabled()
             upEnabled?.preferenceDataStore = object : PreferenceDataStore() {
@@ -102,7 +148,7 @@ class SettingsActivity : AppCompatActivity() {
 
             // UnifiedPush Base URL
             val appBaseUrl = context?.getString(R.string.app_base_url) ?: return
-            val upBaseUrlPrefId = context?.getString(R.string.pref_unified_push_base_url) ?: return
+            val upBaseUrlPrefId = context?.getString(R.string.settings_unified_push_base_url_key) ?: return
             val upBaseUrl: EditTextPreference? = findPreference(upBaseUrlPrefId)
             upBaseUrl?.text = repository.getUnifiedPushBaseUrl() ?: ""
             upBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
@@ -123,7 +169,7 @@ class SettingsActivity : AppCompatActivity() {
             }
 
             // Version
-            val versionPrefId = context?.getString(R.string.pref_version) ?: return
+            val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
             val versionPref: Preference? = findPreference(versionPrefId)
             val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
             versionPref?.summary = version
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index 23c3747..50fb66c 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -28,6 +28,17 @@ fun toPriority(priority: Int?): Int {
     else return 3
 }
 
+fun toPriorityString(priority: Int): String {
+    return when (priority) {
+        1 -> "min"
+        2 -> "low"
+        3 -> "default"
+        4 -> "high"
+        5 -> "max"
+        else -> "default"
+    }
+}
+
 fun joinTags(tags: List<String>?): String {
     return tags?.joinToString(",") ?: ""
 }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4d97b73..8260b34 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -32,7 +32,7 @@
     <string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
     <string name="main_menu_settings_title">Settings</string>
     <string name="main_menu_source_title">Report a bug</string>
-    <string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
+    <string name="main_menu_source_url">https://github.com/binwiederhier/ntfy/issues</string>
     <string name="main_menu_website_title">Visit ntfy.sh</string>
 
     <!-- Main activity: Action mode -->
@@ -147,26 +147,38 @@
     <!-- Settings -->
     <string name="settings_title">Settings</string>
     <string name="settings_notifications_header">Notifications</string>
-    <string name="settings_notifications_header_summary">General settings for all subscribed topics</string>
+    <string name="settings_notifications_muted_until_key">MutedUntil</string>
     <string name="settings_notifications_muted_until_title">Pause notifications</string>
     <string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string>
     <string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string>
     <string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string>
+    <string name="settings_notifications_min_priority_key">MinPriority</string>
+    <string name="settings_notifications_min_priority_title">Minimum priority</string>
+    <string name="settings_notifications_min_priority_summary_any">Notifications of all priorities are shown</string>
+    <string name="settings_notifications_min_priority_summary_x_or_higher">Show notifications if priority is %1$d (%2$s) or higher</string>
+    <string name="settings_notifications_min_priority_summary_max">Show notifications if priority is 5 (max)</string>
+    <string name="settings_notifications_min_priority_min">Any priority</string>
+    <string name="settings_notifications_min_priority_low">Low priority and higher</string>
+    <string name="settings_notifications_min_priority_default">Default priority and higher</string>
+    <string name="settings_notifications_min_priority_high">High priority and higher</string>
+    <string name="settings_notifications_min_priority_max">Only max priority</string>
     <string name="settings_unified_push_header">UnifiedPush</string>
     <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
+    <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>
     <string name="settings_unified_push_enabled_title">Enable distributor</string>
     <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
     <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
+    <string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string>
     <string name="settings_unified_push_base_url_title">Server URL</string>
     <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string>
+    <string name="settings_advanced_header">Advanced</string>
+    <string name="settings_advanced_broadcast_key">BroadcastEnabled</string>
+    <string name="settings_advanced_broadcast_title">Broadcast messages</string>
+    <string name="settings_advanced_broadcast_summary_enabled">Apps can receive incoming notifications as broadcasts</string>
+    <string name="settings_advanced_broadcast_summary_disabled">Apps cannot receive notifications as broadcasts</string>
     <string name="settings_about_header">About</string>
+    <string name="settings_about_version_key">Version</string>
     <string name="settings_about_version_title">Version</string>
     <string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
     <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string>
-
-    <!-- Preferences IDs -->
-    <string name="pref_notifications_muted_until">MutedUntil</string>
-    <string name="pref_unified_push_enabled">UnifiedPushEnabled</string>
-    <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string>
-    <string name="pref_version">Version</string>
 </resources>
diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml
new file mode 100644
index 0000000..4dfb50c
--- /dev/null
+++ b/app/src/main/res/values/values.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string-array name="settings_notifications_min_priority_entries">
+        <item>@string/settings_notifications_min_priority_min</item>
+        <item>@string/settings_notifications_min_priority_low</item>
+        <item>@string/settings_notifications_min_priority_default</item>
+        <item>@string/settings_notifications_min_priority_high</item>
+        <item>@string/settings_notifications_min_priority_max</item>
+    </string-array>
+    <string-array name="settings_notifications_min_priority_values">
+        <item>1</item>
+        <item>2</item>
+        <item>3</item>
+        <item>4</item>
+        <item>5</item>
+    </string-array>
+</resources>
diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml
index c404af4..5295d26 100644
--- a/app/src/main/res/xml/main_preferences.xml
+++ b/app/src/main/res/xml/main_preferences.xml
@@ -3,28 +3,39 @@
                   app:title="@string/settings_title">
     <PreferenceCategory
             app:title="@string/settings_notifications_header"
-            app:summary="@string/settings_notifications_header_summary"
             app:layout="@layout/preference_category_material_edited">
         <Preference
-                app:key="@string/pref_notifications_muted_until"
+                app:key="@string/settings_notifications_muted_until_key"
                 app:title="@string/settings_notifications_muted_until_title"/>
+        <ListPreference
+            app:key="@string/settings_notifications_min_priority_key"
+            app:title="@string/settings_notifications_min_priority_title"
+            app:entries="@array/settings_notifications_min_priority_entries"
+            app:entryValues="@array/settings_notifications_min_priority_values"
+            app:defaultValue="1"/>
     </PreferenceCategory>
     <PreferenceCategory
             app:title="@string/settings_unified_push_header"
             app:summary="@string/settings_unified_push_header_summary"
             app:layout="@layout/preference_category_material_edited">
         <SwitchPreference
-                app:key="@string/pref_unified_push_enabled"
+                app:key="@string/settings_unified_push_enabled_key"
                 app:title="@string/settings_unified_push_enabled_title"
                 app:enabled="true"/>
         <EditTextPreference
-                app:key="@string/pref_unified_push_base_url"
+                app:key="@string/settings_unified_push_base_url_key"
                 app:title="@string/settings_unified_push_base_url_title"
-                app:dependency="@string/pref_unified_push_enabled"/>
+                app:dependency="@string/settings_unified_push_enabled_key"/>
+    </PreferenceCategory>
+    <PreferenceCategory app:title="@string/settings_advanced_header">
+        <SwitchPreference
+                app:key="@string/settings_advanced_broadcast_key"
+                app:title="@string/settings_advanced_broadcast_title"
+                app:enabled="true"/>
     </PreferenceCategory>
     <PreferenceCategory app:title="@string/settings_about_header">
         <Preference
-                app:key="@string/pref_version"
+                app:key="@string/settings_about_version_key"
                 app:title="@string/settings_about_version_title"/>
     </PreferenceCategory>
 </PreferenceScreen>

From bf419dda23b6ebac06372819d03aeb254d24fe01 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Sat, 1 Jan 2022 17:09:00 +0100
Subject: [PATCH 16/16] Always show UP subscriptions at the bottom

---
 app/src/main/java/io/heckel/ntfy/data/Database.kt   |  4 ++--
 app/src/main/java/io/heckel/ntfy/data/Repository.kt |  1 -
 app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt  | 10 ++++++----
 app/src/main/res/values/strings.xml                 |  2 +-
 4 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 8fc2325..578663f 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -129,7 +129,7 @@ interface SubscriptionDao {
         FROM Subscription AS s
         LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
         GROUP BY s.id
-        ORDER BY MAX(n.timestamp) DESC
+        ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
     """)
     fun listFlow(): Flow<List<SubscriptionWithMetadata>>
 
@@ -142,7 +142,7 @@ interface SubscriptionDao {
         FROM Subscription AS s
         LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
         GROUP BY s.id
-        ORDER BY MAX(n.timestamp) DESC
+        ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
     """)
     fun list(): List<SubscriptionWithMetadata>
 
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 0907ca4..bf4971d 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -4,7 +4,6 @@ import android.content.SharedPreferences
 import android.util.Log
 import androidx.annotation.WorkerThread
 import androidx.lifecycle.*
-import androidx.preference.PreferenceManager
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.atomic.AtomicLong
 
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
index 6a361ed..72c926e 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
@@ -56,7 +56,8 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
 
         fun bind(subscription: Subscription) {
             this.subscription = subscription
-            var statusMessage = if (subscription.upAppId != null) {
+            val isUnifiedPush = subscription.upAppId != null
+            var statusMessage = if (isUnifiedPush) {
                 context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
             } else if (subscription.totalCount == 1) {
                 context.getString(R.string.main_item_status_text_one, subscription.totalCount)
@@ -80,15 +81,16 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
                 dateStr
             }
             val globalMutedUntil = repository.getGlobalMutedUntil()
-            val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && subscription.upAppId == null
-            val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && subscription.upAppId == null
+            val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush
+            val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush
             nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
             statusView.text = statusMessage
             dateView.text = dateText
+            dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
             notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
             notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
             instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
-            if (subscription.upAppId != null || subscription.newCount == 0) {
+            if (isUnifiedPush || subscription.newCount == 0) {
                 newItemsView.visibility = View.GONE
             } else {
                 newItemsView.visibility = View.VISIBLE
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8260b34..1767b78 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -165,7 +165,7 @@
     <string name="settings_unified_push_header">UnifiedPush</string>
     <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
     <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>
-    <string name="settings_unified_push_enabled_title">Enable distributor</string>
+    <string name="settings_unified_push_enabled_title">Allow distributor use</string>
     <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
     <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
     <string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string>