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) } } }