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 @@
-
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
@@ -80,5 +107,4 @@
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/>
-
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>
@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
@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> {
+ 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() // 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(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
+ val work = PeriodicWorkRequestBuilder(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(TopicDiffCallback) {
val selected = mutableSetOf() // 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, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
+ class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set, 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 { 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 { 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 { 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 { 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>) {
- 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 {
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 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 @@
+
+
+
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"/>
+ android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
+ app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"/>
+
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 @@
Notifications enabled
Notifications disabled
Notifications disabled until %1$s
+ Settings
Report a bug
- https://heckel.io/ntfy-android
+ https://github.com/binwiederhier/ntfy/issues
Visit ntfy.sh
@@ -47,6 +48,7 @@
%1$d notification
%1$d notifications
reconnecting …
+ %1$s (UnifiedPush)
Yesterday
Add subscription
It looks like you don\'t have any subscriptions yet.
@@ -56,6 +58,7 @@
For more detailed instructions, check out the ntfy.sh website and documentation.
+ This subscription is managed by %1$s via UnifiedPush
Subscribe to topic
@@ -80,10 +83,13 @@
Subscribed to topic %1$s
You haven\'t received any notifications for this topic yet.
- To send notifications to this topic, simply PUT or POST to the topic URL.
+ To send notifications to this topic, simply PUT or POST to the topic URL.
+
$ curl -d \"Hi\" %1$s ]]>
- For more detailed instructions, check out the ntfy.sh website and documentation.
- Do you really want to delete all of the notifications in this topic?
+ For more detailed instructions, check out the ntfy.sh website and documentation.
+
+ Do you really want to delete all of the notifications in this topic?
+
Permanently delete
Cancel
@@ -93,7 +99,9 @@
Permanently delete
Cancel
Test: You can set a title if you like
- 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.
+ 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.
+
Could not send test message: %1$s
Copied to clipboard
Instant delivery enabled
@@ -135,4 +143,42 @@
8 hours
Until tomorrow
Forever
+
+
+ Settings
+ Notifications
+ MutedUntil
+ Pause notifications
+ All notifications will be displayed
+ Notifications muted until re-enabled
+ Notifications muted until %1$s
+ MinPriority
+ Minimum priority
+ Notifications of all priorities are shown
+ Show notifications if priority is %1$d (%2$s) or higher
+ Show notifications if priority is 5 (max)
+ Any priority
+ Low priority and higher
+ Default priority and higher
+ High priority and higher
+ Only max priority
+ UnifiedPush
+ Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.
+ UnifiedPushEnabled
+ Allow distributor use
+ Apps can use ntfy as distributor
+ Apps cannot use ntfy as distributor
+ UnifiedPushBaseURL
+ Server URL
+ %1$s (default)
+ Advanced
+ BroadcastEnabled
+ Broadcast messages
+ Apps can receive incoming notifications as broadcasts
+ Apps cannot receive notifications as broadcasts
+ About
+ Version
+ Version
+ ntfy %1$s (%2$s)
+ Copied to clipboard
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 @@
+
+
+
+ - @string/settings_notifications_min_priority_min
+ - @string/settings_notifications_min_priority_low
+ - @string/settings_notifications_min_priority_default
+ - @string/settings_notifications_min_priority_high
+ - @string/settings_notifications_min_priority_max
+
+
+ - 1
+ - 2
+ - 3
+ - 4
+ - 5
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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)
}
}
}