diff --git a/app/build.gradle b/app/build.gradle
index 24d40a4..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"
 
@@ -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/schemas/io.heckel.ntfy.data.Database/5.json b/app/schemas/io.heckel.ntfy.data.Database/5.json
new file mode 100644
index 0000000..dd398a4
--- /dev/null
+++ b/app/schemas/io.heckel.ntfy.data.Database/5.json
@@ -0,0 +1,158 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 5,
+    "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, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "topic",
+            "columnName": "topic",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instant",
+            "columnName": "instant",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "mutedUntil",
+            "columnName": "mutedUntil",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "upAppId",
+            "columnName": "upAppId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "upConnectorToken",
+            "columnName": "upConnectorToken",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "index_Subscription_baseUrl_topic",
+            "unique": true,
+            "columnNames": [
+              "baseUrl",
+              "topic"
+            ],
+            "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": []
+      },
+      {
+        "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, '306578182c2ad0f9803956beda094d28')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 931bab1..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,27 +44,54 @@
                     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=".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"
-                  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">
+            <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"
@@ -72,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"/>
@@ -80,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/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 7457cfe..578663f 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -6,20 +6,22 @@ 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?,
     @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,14 @@ abstract class Database : RoomDatabase() {
                 db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
             }
         }
+
+        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")
+                db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
+            }
+        }
     }
 }
 
@@ -109,33 +122,33 @@ 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
         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>>
 
     @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
         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>
 
     @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,17 +161,30 @@ 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
         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 ccff84f..bf4971d 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? {
@@ -48,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) {
@@ -85,16 +97,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(notify = notify, broadcast = true, muted = muted)
+        if (maybeExistingNotification != null) {
+            return false
         }
-        return NotificationAddResult(notify = false, broadcast = false, muted = false)
+        notificationDao.add(notification)
+        return true
     }
 
     @Suppress("RedundantSuspendModifier")
@@ -133,15 +142,60 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
             .apply()
     }
 
-    private suspend fun isMuted(subscriptionId: Long): Boolean {
-        if (isGlobalMuted()) {
-            return true
+    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()
         }
-        val s = getSubscription(subscriptionId) ?: return true
-        return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
     }
 
-    private fun isGlobalMuted(): Boolean {
+    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
+    }
+
+    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()
+        }
+    }
+
+    fun isGlobalMuted(): Boolean {
         val mutedUntil = getGlobalMutedUntil()
         return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
     }
@@ -177,6 +231,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 +251,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,
@@ -224,17 +282,15 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
         return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
     }
 
-    data class NotificationAddResult(
-        val notify: Boolean,
-        val broadcast: Boolean,
-        val muted: Boolean,
-    )
-
     companion object {
         const val SHARED_PREFS_ID = "MainPreferences"
         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"
 
         private const val TAG = "NtfyRepository"
         private var instance: Repository? = null
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
new file mode 100644
index 0000000..c4cec51
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt
@@ -0,0 +1,70 @@
+package io.heckel.ntfy.msg
+
+import android.content.Context
+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
+
+/**
+ * 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)
+    private val distributor = Distributor(context)
+
+    fun init() {
+        notifier.createNotificationChannels()
+    }
+
+    fun dispatch(subscription: Subscription, notification: Notification) {
+        val muted = getMuted(subscription)
+        val notify = shouldNotify(subscription, notification, muted)
+        val broadcast = shouldBroadcast(subscription)
+        val distribute = shouldDistribute(subscription)
+        if (notify) {
+            notifier.send(subscription, notification)
+        }
+        if (broadcast) {
+            broadcaster.send(subscription, notification, muted)
+        }
+        if (distribute) {
+            safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
+                distributor.sendMessage(appId, connectorToken, notification.message)
+            }
+        }
+    }
+
+    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 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
+        }
+        return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
+    }
+}
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 86%
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 8a0add5..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.*
@@ -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
 
@@ -71,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 {
@@ -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)
             }
         }
     }
@@ -265,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)
         }
     }
 
@@ -282,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..a4ecfbb
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt
@@ -0,0 +1,63 @@
+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.
+ *
+ * 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() {
+        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)
+    }
+
+    /**
+     * 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) {
+                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..599d5fb 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,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 subscriberManager: SubscriberManager? = null // Context-dependent
     private var notifier: NotificationService? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
@@ -72,7 +68,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
         Log.d(MainActivity.TAG, "Create $this")
 
         // Dependencies that depend on Context
-        subscriberManager = SubscriberManager(this)
         notifier = NotificationService(this)
         appBaseUrl = getString(R.string.app_base_url)
 
@@ -149,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
 
         // React to changes in fast delivery setting
         repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
-            subscriberManager?.refreshService(it)
+            SubscriberServiceManager.refresh(this)
         }
 
         // Mark this subscription as "open" so we don't receive notifications for it
@@ -423,7 +418,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 a1b9a9e..241c2e2 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,11 @@ 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.service.SubscriberService
+import io.heckel.ntfy.service.SubscriberServiceManager
 import io.heckel.ntfy.util.fadeStatusBarColor
 import io.heckel.ntfy.util.formatDateShort
 import kotlinx.coroutines.Dispatchers
@@ -54,9 +53,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
     // Other stuff
     private var actionMode: ActionMode? = null
     private var workManager: WorkManager? = 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 dispatcher: NotificationDispatcher? = null // Context-dependent
     private var appBaseUrl: String? = null // Context-dependent
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -67,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
 
         // Dependencies that depend on Context
         workManager = WorkManager.getInstance(this)
-        notifier = NotificationService(this)
-        broadcaster = BroadcastService(this)
-        subscriberManager = SubscriberManager(this)
+        dispatcher = NotificationDispatcher(this, repository)
         appBaseUrl = getString(R.string.app_base_url)
 
         // Action bar
@@ -92,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) {
@@ -108,20 +103,20 @@ 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)
+            SubscriberServiceManager.refresh(this)
         }
 
         // 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)
 
         // Background things
         startPeriodicPollWorker()
-        startPeriodicAutoRestartWorker()
+        startPeriodicServiceRefreshWorker()
     }
 
     private fun startPeriodicPollWorker() {
@@ -146,7 +141,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")
@@ -156,12 +151,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 {
@@ -174,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")
 
@@ -235,6 +230,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
@@ -262,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()
@@ -288,6 +288,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
             topic = topic,
             instant = instant,
             mutedUntil = 0,
+            upAppId = null,
+            upConnectorToken = null,
             totalCount = 0,
             newCount = 0,
             lastActive = Date().time/1000
@@ -317,11 +319,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 ?: return@runOnUiThread
+            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)
@@ -341,12 +353,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
                     newNotifications.forEach { notification ->
                         newNotificationsCount++
                         val notificationWithId = notification.copy(notificationId = Random.nextInt())
-                        val result = repository.addNotification(notificationWithId)
-                        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) {
@@ -422,7 +430,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..72c926e 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
@@ -55,7 +56,10 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
 
         fun bind(subscription: Subscription) {
             this.subscription = subscription
-            var statusMessage = if (subscription.totalCount == 1) {
+            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)
             } else {
                 context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
@@ -76,17 +80,21 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
             } else {
                 dateStr
             }
+            val globalMutedUntil = repository.getGlobalMutedUntil()
+            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
-            notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
-            notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
+            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.newCount > 0) {
+            if (isUnifiedPush || 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/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
new file mode 100644
index 0000000..5a60915
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -0,0 +1,188 @@
+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.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
+import io.heckel.ntfy.util.toPriorityString
+
+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, supportFragmentManager))
+                .commit()
+        }
+
+        // Action bar
+        title = getString(R.string.settings_title)
+
+        // Show 'Back' button
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+    }
+
+    class SettingsFragment(val repository: Repository, private val supportFragmentManager: FragmentManager) : PreferenceFragmentCompat() {
+        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.
+
+            // Notifications muted until (global)
+            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)
+                    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
+            }
+
+            // 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() {
+                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.settings_unified_push_base_url_key) ?: 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.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
+            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/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
deleted file mode 100644
index a348d8a..0000000
--- a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package io.heckel.ntfy.ui
-
-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.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>>) {
-        Log.d(MainActivity.TAG, "Triggering subscriber service refresh")
-        activity.lifecycleScope.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(activity)
-        if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) {
-            return
-        }
-        val intent = Intent(activity, 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)
-        } else {
-            Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
-            activity.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
new file mode 100644
index 0000000..2a7d64c
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
@@ -0,0 +1,113 @@
+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.Subscription
+import io.heckel.ntfy.service.SubscriberServiceManager
+import io.heckel.ntfy.util.randomString
+import io.heckel.ntfy.util.topicUrlUp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+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) {
+            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 (!repository.getUnifiedPushEnabled() || appId.isBlank()) {
+            Log.w(TAG, "Refusing registration: UnifiedPush disabled or 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
+            }
+
+            // Add subscription
+            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(),
+                baseUrl = baseUrl,
+                topic = topic,
+                instant = true, // No Firebase, always instant!
+                mutedUntil = 0,
+                upAppId = appId,
+                upConnectorToken = connectorToken,
+                totalCount = 0,
+                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)
+
+            // Refresh (and maybe start) foreground service
+            SubscriberServiceManager.refresh(app)
+        }
+    }
+
+    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
+            SubscriberServiceManager.refresh(context)
+        }
+    }
+
+    companion object {
+        private const val TAG = "NtfyUpBroadcastRecv"
+        private const val UP_PREFIX = "up"
+        private const val TOPIC_RANDOM_ID_LENGTH = 12
+    }
+}
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/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
new file mode 100644
index 0000000..38273a9
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt
@@ -0,0 +1,53 @@
+package io.heckel.ntfy.up
+
+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")
+        val broadcastIntent = Intent()
+        broadcastIntent.`package` = app
+        broadcastIntent.action = ACTION_MESSAGE
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
+        broadcastIntent.putExtra(EXTRA_MESSAGE, message)
+        context.sendBroadcast(broadcastIntent)
+    }
+
+    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
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
+        broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint)
+        context.sendBroadcast(broadcastIntent)
+    }
+
+    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
+        broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
+        context.sendBroadcast(broadcastIntent)
+    }
+
+    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/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index b5f4a08..50fb66c 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -5,10 +5,12 @@ 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.*
 
 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) =
@@ -26,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(",") ?: ""
 }
@@ -101,3 +114,15 @@ 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()
+    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
+}
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/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 5fe54e6..1767b78 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -30,8 +30,9 @@
     <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_source_url">https://github.com/binwiederhier/ntfy/issues</string>
     <string name="main_menu_website_title">Visit ntfy.sh</string>
 
     <!-- Main activity: Action mode -->
@@ -47,6 +48,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>
@@ -56,6 +58,7 @@
     </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 -->
     <string name="add_dialog_title">Subscribe to topic</string>
@@ -80,10 +83,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">
@@ -93,7 +99,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>
@@ -135,4 +143,42 @@
     <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_notifications_header">Notifications</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">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>
+    <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>
 </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
new file mode 100644
index 0000000..5295d26
--- /dev/null
+++ b/app/src/main/res/xml/main_preferences.xml
@@ -0,0 +1,41 @@
+<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:layout="@layout/preference_category_material_edited">
+        <Preference
+                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/settings_unified_push_enabled_key"
+                app:title="@string/settings_unified_push_enabled_title"
+                app:enabled="true"/>
+        <EditTextPreference
+                app:key="@string/settings_unified_push_base_url_key"
+                app:title="@string/settings_unified_push_base_url_title"
+                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/settings_about_version_key"
+                app:title="@string/settings_about_version_title"/>
+    </PreferenceCategory>
+</PreferenceScreen>
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..a06ea2c 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,8 @@ 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.service.SubscriberService
 import io.heckel.ntfy.util.toPriority
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob
@@ -19,9 +17,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 +78,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)
             }
         }
     }