From 94e595110d825fb9031c489d40c27d986b4139ca Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Wed, 29 Dec 2021 20:33:17 +0100 Subject: [PATCH 01/16] WIP: UnifiedPush --- .../io.heckel.ntfy.data.Database/5.json | 150 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 8 + .../main/java/io/heckel/ntfy/data/Database.kt | 26 ++- .../java/io/heckel/ntfy/data/Repository.kt | 10 +- .../heckel/ntfy/msg/NotificationDispatcher.kt | 31 ++++ .../java/io/heckel/ntfy/ui/MainActivity.kt | 10 +- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 62 ++++++++ .../main/java/io/heckel/ntfy/up/Constants.kt | 22 +++ .../io/heckel/ntfy/up/DistributorUtils.kt | 34 ++++ app/src/main/java/io/heckel/ntfy/util/Util.kt | 1 + 10 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.data.Database/5.json create mode 100644 app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt create mode 100644 app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt create mode 100644 app/src/main/java/io/heckel/ntfy/up/Constants.kt create mode 100644 app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt diff --git a/app/schemas/io.heckel.ntfy.data.Database/5.json b/app/schemas/io.heckel.ntfy.data.Database/5.json new file mode 100644 index 0000000..0620854 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.data.Database/5.json @@ -0,0 +1,150 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "d72d045ad4ad20db887b4c6aed3da27b", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT NOT NULL, `upConnectorToken` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutedUntil", + "columnName": "mutedUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upAppId", + "columnName": "upAppId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "upConnectorToken", + "columnName": "upConnectorToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Subscription_baseUrl_topic", + "unique": true, + "columnNames": [ + "baseUrl", + "topic" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id", + "subscriptionId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd72d045ad4ad20db887b4c6aed3da27b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 931bab1..1beaca1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,14 @@ </intent-filter> </receiver> + <!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md --> + <receiver android:name=".up.BroadcastReceiver" android:enabled="true" android:exported="true"> + <intent-filter> + <action android:name="org.unifiedpush.android.distributor.REGISTER" /> + <action android:name="org.unifiedpush.android.distributor.UNREGISTER" /> + </intent-filter> + </receiver> + <!-- Firebase messaging (note that this is empty in the F-Droid flavor) --> <service android:name=".firebase.FirebaseService" diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 7457cfe..240342b 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -13,13 +13,15 @@ data class Subscription( @ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule + @ColumnInfo(name = "upAppId") val upAppId: String, + @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String, @Ignore val totalCount: Int = 0, // Total notifications @Ignore val newCount: Int = 0, // New notifications @Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE ) { - constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) : - this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE) + constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) : + this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) } enum class ConnectionState { @@ -32,6 +34,8 @@ data class SubscriptionWithMetadata( val topic: String, val instant: Boolean, val mutedUntil: Long, + val upAppId: String, + val upConnectorToken: String, val totalCount: Int, val newCount: Int, val lastActive: Long @@ -50,7 +54,7 @@ data class Notification( @ColumnInfo(name = "deleted") val deleted: Boolean, ) -@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 4) +@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 5) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao @@ -66,6 +70,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_2_3) .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_4_5) .fallbackToDestructiveMigration() .build() this.instance = instance @@ -102,6 +107,13 @@ abstract class Database : RoomDatabase() { db.execSQL("ALTER TABLE Notification_New RENAME TO Notification") } } + + private val MIGRATION_4_5 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT NOT NULL DEFAULT('')") + db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT NOT NULL DEFAULT('')") + } + } } } @@ -109,7 +121,7 @@ abstract class Database : RoomDatabase() { interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -122,7 +134,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -135,7 +147,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -148,7 +160,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index ccff84f..5caacd4 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -92,9 +92,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId val muted = isMuted(notification.subscriptionId) val notify = !detailsVisible && !muted - return NotificationAddResult(notify = notify, broadcast = true, muted = muted) + return NotificationAddResult(notification = notification, notify = notify, broadcast = true, muted = muted) } - return NotificationAddResult(notify = false, broadcast = false, muted = false) + return NotificationAddResult(notification = notification, notify = false, broadcast = false, forward = false, muted = false) } @Suppress("RedundantSuspendModifier") @@ -177,6 +177,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri topic = s.topic, instant = s.instant, mutedUntil = s.mutedUntil, + upAppId = s.upAppId, + upConnectorToken = s.upConnectorToken, totalCount = s.totalCount, newCount = s.newCount, lastActive = s.lastActive, @@ -195,6 +197,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri topic = s.topic, instant = s.instant, mutedUntil = s.mutedUntil, + upAppId = s.upAppId, + upConnectorToken = s.upConnectorToken, totalCount = s.totalCount, newCount = s.newCount, lastActive = s.lastActive, @@ -225,8 +229,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri } data class NotificationAddResult( + val notification: Notification, val notify: Boolean, val broadcast: Boolean, + val forward: Boolean, // Forward to UnifiedPush connector val muted: Boolean, ) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt new file mode 100644 index 0000000..f981e05 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -0,0 +1,31 @@ +package io.heckel.ntfy.msg + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import io.heckel.ntfy.R +import io.heckel.ntfy.data.Notification +import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.ui.DetailActivity +import io.heckel.ntfy.ui.MainActivity +import io.heckel.ntfy.util.formatMessage +import io.heckel.ntfy.util.formatTitle + +class NotificationDispatcher(val context: Context) { + fun dispatch(subscription: Subscription, notification: Notification) { + + } + + companion object { + private const val TAG = "NtfyNotificationDispatcher" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index a1b9a9e..a4d03b6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -20,12 +20,9 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.util.topicShortUrl -import io.heckel.ntfy.msg.ApiService -import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.firebase.FirebaseMessenger -import io.heckel.ntfy.msg.BroadcastService -import io.heckel.ntfy.msg.SubscriberService +import io.heckel.ntfy.msg.* import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.formatDateShort import kotlinx.coroutines.Dispatchers @@ -54,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Other stuff private var actionMode: ActionMode? = null private var workManager: WorkManager? = null // Context-dependent + private var dispatcher: NotificationDispatcher? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent private var broadcaster: BroadcastService? = null // Context-dependent private var subscriberManager: SubscriberManager? = null // Context-dependent @@ -67,6 +65,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Dependencies that depend on Context workManager = WorkManager.getInstance(this) + dispatcher = NotificationDispatcher(this) notifier = NotificationService(this) broadcaster = BroadcastService(this) subscriberManager = SubscriberManager(this) @@ -288,6 +287,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc topic = topic, instant = instant, mutedUntil = 0, + upAppId = "", + upConnectorToken = "", totalCount = 0, newCount = 0, lastActive = Date().time/1000 @@ -342,6 +343,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc newNotificationsCount++ val notificationWithId = notification.copy(notificationId = Random.nextInt()) val result = repository.addNotification(notificationWithId) + dispatcher?.dispatch() if (result.notify) { notifier?.send(subscription, notificationWithId) } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt new file mode 100644 index 0000000..1fa8c61 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -0,0 +1,62 @@ +package io.heckel.ntfy.up + +import android.content.Context +import android.content.Intent +import android.util.Log +import io.heckel.ntfy.R +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.* +import kotlin.random.Random + +class BroadcastReceiver : android.content.BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent!!.action) { + ACTION_REGISTER -> { + val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: "" + val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" + Log.d(TAG, "Register: app=$appId, connectorToken=$connectorToken") + if (appId.isBlank()) { + Log.w(TAG, "Trying to register an app without packageName") + return + } + + val baseUrl = context!!.getString(R.string.app_base_url) // FIXME + val topic = connectorToken // FIXME + val app = context!!.applicationContext as Application + val repository = app.repository + val subscription = Subscription( + id = Random.nextLong(), + baseUrl = baseUrl, + topic = topic, + instant = true, + mutedUntil = 0, + upAppId = appId, + upConnectorToken = connectorToken, + totalCount = 0, + newCount = 0, + lastActive = Date().time/1000 + ) + GlobalScope.launch(Dispatchers.IO) { + repository.addSubscription(subscription) + } + + sendEndpoint(context!!, appId, connectorToken) + // XXXXXXXXX + } + ACTION_UNREGISTER -> { + val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" + Log.d(TAG, "Unregister: connectorToken=$connectorToken") + // XXXXXXX + sendUnregistered(context!!, "org.unifiedpush.example", connectorToken) + } + } + } + + companion object { + private const val TAG = "NtfyUpBroadcastRecv" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/up/Constants.kt b/app/src/main/java/io/heckel/ntfy/up/Constants.kt new file mode 100644 index 0000000..6ee8b41 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/Constants.kt @@ -0,0 +1,22 @@ +package io.heckel.ntfy.up + +/** + * Constants as defined on the specs + * https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md + */ + +const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT" +const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED" +const val ACTION_REGISTRATION_REFUSED = "org.unifiedpush.android.connector.REGISTRATION_REFUSED" +const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED" +const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE" + +const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER" +const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER" +const val ACTION_MESSAGE_ACK = "org.unifiedpush.android.distributor.MESSAGE_ACK" + +const val EXTRA_APPLICATION = "application" +const val EXTRA_TOKEN = "token" +const val EXTRA_ENDPOINT = "endpoint" +const val EXTRA_MESSAGE = "message" +const val EXTRA_MESSAGE_ID = "id" diff --git a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt new file mode 100644 index 0000000..a7ba475 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt @@ -0,0 +1,34 @@ +package io.heckel.ntfy.up + +import android.content.Context +import android.content.Intent +import io.heckel.ntfy.R +import io.heckel.ntfy.util.topicUrlUp + +fun sendMessage(context: Context, app: String, token: String, message: String) { + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_MESSAGE + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_MESSAGE, message) + context.sendBroadcast(broadcastIntent) +} + +fun sendEndpoint(context: Context, app: String, token: String) { + val appBaseUrl = context.getString(R.string.app_base_url) + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_NEW_ENDPOINT + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token)) + context.sendBroadcast(broadcastIntent) +} + +fun sendUnregistered(context: Context, app: String, token: String) { + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_UNREGISTERED + broadcastIntent.putExtra(EXTRA_TOKEN, token) + context.sendBroadcast(broadcastIntent) +} + diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index b5f4a08..1d8dcff 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -9,6 +9,7 @@ import java.text.DateFormat import java.util.* fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" +fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since" fun topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1" fun topicShortUrl(baseUrl: String, topic: String) = From 7e9da287049656f9ca879b71bf1c2d97b268e6a5 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Wed, 29 Dec 2021 21:36:47 +0100 Subject: [PATCH 02/16] Forward messages, don't show ntfy notification --- .../java/io/heckel/ntfy/data/Repository.kt | 23 ++------ .../heckel/ntfy/msg/NotificationDispatcher.kt | 55 +++++++++++++------ .../io/heckel/ntfy/msg/SubscriberService.kt | 18 ++---- .../java/io/heckel/ntfy/ui/MainActivity.kt | 17 ++---- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 6 +- .../java/io/heckel/ntfy/up/Distributor.kt | 36 ++++++++++++ .../io/heckel/ntfy/up/DistributorUtils.kt | 34 ------------ .../java/io/heckel/ntfy/work/PollWorker.kt | 13 ++--- .../heckel/ntfy/firebase/FirebaseService.kt | 21 ++----- 9 files changed, 104 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/up/Distributor.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 5caacd4..e953911 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -85,16 +85,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri @Suppress("RedundantSuspendModifier") @WorkerThread - suspend fun addNotification(notification: Notification): NotificationAddResult { + suspend fun addNotification(notification: Notification): Boolean { val maybeExistingNotification = notificationDao.get(notification.id) - if (maybeExistingNotification == null) { - notificationDao.add(notification) - val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId - val muted = isMuted(notification.subscriptionId) - val notify = !detailsVisible && !muted - return NotificationAddResult(notification = notification, notify = notify, broadcast = true, muted = muted) + if (maybeExistingNotification != null) { + return false } - return NotificationAddResult(notification = notification, notify = false, broadcast = false, forward = false, muted = false) + notificationDao.add(notification) + return true } @Suppress("RedundantSuspendModifier") @@ -141,7 +138,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000) } - private fun isGlobalMuted(): Boolean { + fun isGlobalMuted(): Boolean { val mutedUntil = getGlobalMutedUntil() return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000) } @@ -228,14 +225,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE } } - data class NotificationAddResult( - val notification: Notification, - val notify: Boolean, - val broadcast: Boolean, - val forward: Boolean, // Forward to UnifiedPush connector - val muted: Boolean, - ) - companion object { const val SHARED_PREFS_ID = "MainPreferences" const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion" diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index f981e05..d4bade2 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -1,28 +1,49 @@ package io.heckel.ntfy.msg -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.TaskStackBuilder import android.content.Context -import android.content.Intent -import android.media.RingtoneManager -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import io.heckel.ntfy.R import io.heckel.ntfy.data.Notification +import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription -import io.heckel.ntfy.ui.DetailActivity -import io.heckel.ntfy.ui.MainActivity -import io.heckel.ntfy.util.formatMessage -import io.heckel.ntfy.util.formatTitle +import io.heckel.ntfy.up.Distributor + +class NotificationDispatcher(val context: Context, val repository: Repository) { + private val notifier = NotificationService(context) + private val broadcaster = BroadcastService(context) + private val distributor = Distributor(context) + + fun init() { + notifier.createNotificationChannels() + } -class NotificationDispatcher(val context: Context) { fun dispatch(subscription: Subscription, notification: Notification) { + val muted = checkMuted(subscription) + val notify = checkNotify(subscription, notification, muted) + val broadcast = subscription.upAppId == "" + val distribute = subscription.upAppId != "" + if (notify) { + notifier.send(subscription, notification) + } + if (broadcast) { + broadcaster.send(subscription, notification, muted) + } + if (distribute) { + distributor.sendMessage(subscription.upAppId, subscription.upConnectorToken, notification.message) + } + } + private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { + if (subscription.upAppId != "") { + return false + } + val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId + return !detailsVisible && !muted + } + + private fun checkMuted(subscription: Subscription): Boolean { + if (repository.isGlobalMuted()) { + return true + } + return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000) } companion object { diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt index 8a0add5..90187bd 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt @@ -58,10 +58,9 @@ class SubscriberService : Service() { private var wakeLock: PowerManager.WakeLock? = null private var isServiceStarted = false private val repository by lazy { (application as Application).repository } + private val dispatcher by lazy { NotificationDispatcher(this, repository) } private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection private val api = ApiService() - private val notifier = NotificationService(this) - private val broadcaster = BroadcastService(this) private var notificationManager: NotificationManager? = null private var serviceNotification: Notification? = null @@ -201,18 +200,13 @@ class SubscriberService : Service() { repository.updateState(subscriptionIds, state) } - private fun onNotificationReceived(subscription: Subscription, n: io.heckel.ntfy.data.Notification) { + private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) { val url = topicUrl(subscription.baseUrl, subscription.topic) - Log.d(TAG, "[$url] Received notification: $n") + Log.d(TAG, "[$url] Received notification: $notification") GlobalScope.launch(Dispatchers.IO) { - val result = repository.addNotification(n) - if (result.notify) { - Log.d(TAG, "[$url] Showing notification: $n") - notifier.send(subscription, n) - } - if (result.broadcast) { - Log.d(TAG, "[$url] Broadcasting notification: $n") - broadcaster.send(subscription, n, result.muted) + if (repository.addNotification(notification)) { + Log.d(TAG, "[$url] Dispatching notification $notification") + dispatcher.dispatch(subscription, notification) } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index a4d03b6..7bd46c6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -52,8 +52,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private var actionMode: ActionMode? = null private var workManager: WorkManager? = null // Context-dependent private var dispatcher: NotificationDispatcher? = null // Context-dependent - private var notifier: NotificationService? = null // Context-dependent - private var broadcaster: BroadcastService? = null // Context-dependent private var subscriberManager: SubscriberManager? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent @@ -65,9 +63,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Dependencies that depend on Context workManager = WorkManager.getInstance(this) - dispatcher = NotificationDispatcher(this) - notifier = NotificationService(this) - broadcaster = BroadcastService(this) + dispatcher = NotificationDispatcher(this, repository) subscriberManager = SubscriberManager(this) appBaseUrl = getString(R.string.app_base_url) @@ -113,7 +109,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc } // Create notification channels right away, so we can configure them immediately after installing the app - notifier!!.createNotificationChannels() + dispatcher?.init() // Subscribe to control Firebase channel (so we can re-start the foreground service if it dies) messenger.subscribe(ApiService.CONTROL_TOPIC) @@ -342,13 +338,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc newNotifications.forEach { notification -> newNotificationsCount++ val notificationWithId = notification.copy(notificationId = Random.nextInt()) - val result = repository.addNotification(notificationWithId) - dispatcher?.dispatch() - if (result.notify) { - notifier?.send(subscription, notificationWithId) - } - if (result.broadcast) { - broadcaster?.send(subscription, notification, result.muted) + if (repository.addNotification(notificationWithId)) { + dispatcher?.dispatch(subscription, notificationWithId) } } } catch (e: Exception) { diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 1fa8c61..7e56035 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -28,6 +28,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { val topic = connectorToken // FIXME val app = context!!.applicationContext as Application val repository = app.repository + val distributor = Distributor(app) val subscription = Subscription( id = Random.nextLong(), baseUrl = baseUrl, @@ -44,14 +45,15 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { repository.addSubscription(subscription) } - sendEndpoint(context!!, appId, connectorToken) + distributor.sendEndpoint(appId, connectorToken) // XXXXXXXXX } ACTION_UNREGISTER -> { val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" Log.d(TAG, "Unregister: connectorToken=$connectorToken") // XXXXXXX - sendUnregistered(context!!, "org.unifiedpush.example", connectorToken) + val distributor = Distributor(context!!) + distributor.sendUnregistered("org.unifiedpush.example", connectorToken) } } } diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt new file mode 100644 index 0000000..975ab58 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt @@ -0,0 +1,36 @@ +package io.heckel.ntfy.up + +import android.content.Context +import android.content.Intent +import io.heckel.ntfy.R +import io.heckel.ntfy.data.Repository +import io.heckel.ntfy.util.topicUrlUp + +class Distributor(val context: Context) { + fun sendMessage(app: String, token: String, message: String) { + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_MESSAGE + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_MESSAGE, message) + context.sendBroadcast(broadcastIntent) + } + + fun sendEndpoint(app: String, token: String) { + val appBaseUrl = context.getString(R.string.app_base_url) // FIXME + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_NEW_ENDPOINT + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token)) + context.sendBroadcast(broadcastIntent) + } + + fun sendUnregistered(app: String, token: String) { + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_UNREGISTERED + broadcastIntent.putExtra(EXTRA_TOKEN, token) + context.sendBroadcast(broadcastIntent) + } +} diff --git a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt b/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt deleted file mode 100644 index a7ba475..0000000 --- a/app/src/main/java/io/heckel/ntfy/up/DistributorUtils.kt +++ /dev/null @@ -1,34 +0,0 @@ -package io.heckel.ntfy.up - -import android.content.Context -import android.content.Intent -import io.heckel.ntfy.R -import io.heckel.ntfy.util.topicUrlUp - -fun sendMessage(context: Context, app: String, token: String, message: String) { - val broadcastIntent = Intent() - broadcastIntent.`package` = app - broadcastIntent.action = ACTION_MESSAGE - broadcastIntent.putExtra(EXTRA_TOKEN, token) - broadcastIntent.putExtra(EXTRA_MESSAGE, message) - context.sendBroadcast(broadcastIntent) -} - -fun sendEndpoint(context: Context, app: String, token: String) { - val appBaseUrl = context.getString(R.string.app_base_url) - val broadcastIntent = Intent() - broadcastIntent.`package` = app - broadcastIntent.action = ACTION_NEW_ENDPOINT - broadcastIntent.putExtra(EXTRA_TOKEN, token) - broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token)) - context.sendBroadcast(broadcastIntent) -} - -fun sendUnregistered(context: Context, app: String, token: String) { - val broadcastIntent = Intent() - broadcastIntent.`package` = app - broadcastIntent.action = ACTION_UNREGISTERED - broadcastIntent.putExtra(EXTRA_TOKEN, token) - context.sendBroadcast(broadcastIntent) -} - diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index 8828e23..43e8003 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -7,8 +7,10 @@ import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Repository +import io.heckel.ntfy.firebase.FirebaseService import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.BroadcastService +import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -25,8 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, val database = Database.getInstance(applicationContext) val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE) val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao()) - val notifier = NotificationService(applicationContext) - val broadcaster = BroadcastService(applicationContext) + val dispatcher = NotificationDispatcher(applicationContext, repository) val api = ApiService() repository.getSubscriptions().forEach{ subscription -> @@ -36,12 +37,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, .onlyNewNotifications(subscription.id, notifications) .map { it.copy(notificationId = Random.nextInt()) } newNotifications.forEach { notification -> - val result = repository.addNotification(notification) - if (result.notify) { - notifier.send(subscription, notification) - } - if (result.broadcast) { - broadcaster.send(subscription, notification, result.muted) + if (repository.addNotification(notification)) { + dispatcher.dispatch(subscription, notification) } } } catch (e: Exception) { diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 61caac9..1df310c 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -7,10 +7,7 @@ import com.google.firebase.messaging.RemoteMessage import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification -import io.heckel.ntfy.msg.ApiService -import io.heckel.ntfy.msg.BroadcastService -import io.heckel.ntfy.msg.NotificationService -import io.heckel.ntfy.msg.SubscriberService +import io.heckel.ntfy.msg.* import io.heckel.ntfy.util.toPriority import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -19,9 +16,8 @@ import kotlin.random.Random class FirebaseService : FirebaseMessagingService() { private val repository by lazy { (application as Application).repository } + private val dispatcher by lazy { NotificationDispatcher(this, repository) } private val job = SupervisorJob() - private val notifier = NotificationService(this) - private val broadcaster = BroadcastService(this) private val messenger = FirebaseMessenger() override fun onMessageReceived(remoteMessage: RemoteMessage) { @@ -81,16 +77,9 @@ class FirebaseService : FirebaseMessagingService() { tags = tags ?: "", deleted = false ) - val result = repository.addNotification(notification) - - // Send notification (only if it's not already known) - if (result.notify) { - Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") - notifier.send(subscription, notification) - } - if (result.broadcast) { - Log.d(TAG, "Sending broadcast for message: from=${remoteMessage.from}, data=${data}") - broadcaster.send(subscription, notification, result.muted) + if (repository.addNotification(notification)) { + Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, data=${data}") + dispatcher.dispatch(subscription, notification) } } } From 7dbbf12c9903c4648e1bcc04f86088d940773f65 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Wed, 29 Dec 2021 23:48:06 +0100 Subject: [PATCH 03/16] Start instant delivery notification --- .../main/java/io/heckel/ntfy/data/Repository.kt | 6 ++++++ .../main/java/io/heckel/ntfy/ui/MainActivity.kt | 2 +- .../java/io/heckel/ntfy/ui/SubscriberManager.kt | 16 +++++++++------- .../java/io/heckel/ntfy/up/BroadcastReceiver.kt | 5 ++++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index e953911..fa6797a 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -36,6 +36,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri return toSubscriptionList(subscriptionDao.list()) } + fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> { + return subscriptionDao + .list() + .map { Pair(it.id, it.instant) }.toSet() + } + @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun getSubscription(subscriptionId: Long): Subscription? { diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 7bd46c6..3a3e813 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc } } - // React to changes in fast delivery setting + // React to changes in instant delivery setting viewModel.listIdsWithInstantStatus().observe(this) { subscriberManager?.refreshService(it) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt index a348d8a..141096e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt @@ -1,5 +1,6 @@ package io.heckel.ntfy.ui +import android.content.Context import android.content.Intent import android.os.Build import android.util.Log @@ -7,16 +8,17 @@ import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import io.heckel.ntfy.msg.SubscriberService import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch /** * This class only manages the SubscriberService, i.e. it starts or stops it. * It's used in multiple activities. */ -class SubscriberManager(private val activity: ComponentActivity) { - fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) { +class SubscriberManager(private val context: Context) { + fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) { // Set<SubscriptionId -> IsInstant> Log.d(MainActivity.TAG, "Triggering subscriber service refresh") - activity.lifecycleScope.launch(Dispatchers.IO) { + GlobalScope.launch(Dispatchers.IO) { val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size if (instantSubscriptions == 0) { performActionOnSubscriberService(SubscriberService.Actions.STOP) @@ -27,18 +29,18 @@ class SubscriberManager(private val activity: ComponentActivity) { } private fun performActionOnSubscriberService(action: SubscriberService.Actions) { - val serviceState = SubscriberService.readServiceState(activity) + val serviceState = SubscriberService.readServiceState(context) if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) { return } - val intent = Intent(activity, SubscriberService::class.java) + val intent = Intent(context, SubscriberService::class.java) intent.action = action.name if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)") - activity.startForegroundService(intent) + context.startForegroundService(intent) } else { Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)") - activity.startService(intent) + context.startService(intent) } } } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 7e56035..71c36bb 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -6,6 +6,7 @@ import android.util.Log import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.ui.SubscriberManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -43,8 +44,10 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { ) GlobalScope.launch(Dispatchers.IO) { repository.addSubscription(subscription) + val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() + val subscriberManager = SubscriberManager(context!!) + subscriberManager.refreshService(subscriptionIdsWithInstantStatus) } - distributor.sendEndpoint(appId, connectorToken) // XXXXXXXXX } From 73f610afa8f3458ee784954962be8a70b2368a50 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Thu, 30 Dec 2021 01:05:32 +0100 Subject: [PATCH 04/16] Full end to end use case works; still ugly though --- .../io.heckel.ntfy.data.Database/5.json | 18 ++++-- .../main/java/io/heckel/ntfy/data/Database.kt | 25 ++++++-- .../java/io/heckel/ntfy/data/Repository.kt | 6 ++ .../heckel/ntfy/msg/NotificationDispatcher.kt | 9 ++- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 60 ++++++++++++------- .../java/io/heckel/ntfy/up/Distributor.kt | 26 ++++---- app/src/main/java/io/heckel/ntfy/util/Util.kt | 12 ++++ 7 files changed, 111 insertions(+), 45 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.data.Database/5.json b/app/schemas/io.heckel.ntfy.data.Database/5.json index 0620854..dd398a4 100644 --- a/app/schemas/io.heckel.ntfy.data.Database/5.json +++ b/app/schemas/io.heckel.ntfy.data.Database/5.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 5, - "identityHash": "d72d045ad4ad20db887b4c6aed3da27b", + "identityHash": "306578182c2ad0f9803956beda094d28", "entities": [ { "tableName": "Subscription", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT NOT NULL, `upConnectorToken` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -42,13 +42,13 @@ "fieldPath": "upAppId", "columnName": "upAppId", "affinity": "TEXT", - "notNull": true + "notNull": false }, { "fieldPath": "upConnectorToken", "columnName": "upConnectorToken", "affinity": "TEXT", - "notNull": true + "notNull": false } ], "primaryKey": { @@ -66,6 +66,14 @@ "topic" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + }, + { + "name": "index_Subscription_upConnectorToken", + "unique": true, + "columnNames": [ + "upConnectorToken" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)" } ], "foreignKeys": [] @@ -144,7 +152,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd72d045ad4ad20db887b4c6aed3da27b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '306578182c2ad0f9803956beda094d28')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 240342b..ad87a96 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -6,15 +6,15 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import kotlinx.coroutines.flow.Flow -@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)]) +@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)]) data class Subscription( @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule - @ColumnInfo(name = "upAppId") val upAppId: String, - @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String, + @ColumnInfo(name = "upAppId") val upAppId: String?, + @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, @Ignore val totalCount: Int = 0, // Total notifications @Ignore val newCount: Int = 0, // New notifications @Ignore val lastActive: Long = 0, // Unix timestamp @@ -110,8 +110,8 @@ abstract class Database : RoomDatabase() { private val MIGRATION_4_5 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT NOT NULL DEFAULT('')") - db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT NOT NULL DEFAULT('')") + db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT") + db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT") } } } @@ -166,11 +166,24 @@ interface SubscriptionDao { IFNULL(MAX(n.timestamp),0) AS lastActive FROM Subscription AS s LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 - WHERE s.id = :subscriptionId + WHERE s.id = :subscriptionId GROUP BY s.id """) fun get(subscriptionId: Long): SubscriptionWithMetadata? + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + WHERE s.upConnectorToken = :connectorToken + GROUP BY s.id + """) + fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata? + @Insert fun add(subscription: Subscription) diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index fa6797a..1d1a2cc 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -54,6 +54,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri return toSubscription(subscriptionDao.get(baseUrl, topic)) } + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? { + return toSubscription(subscriptionDao.getByConnectorToken(connectorToken)) + } + @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun addSubscription(subscription: Subscription) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index d4bade2..aa251cd 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -5,6 +5,7 @@ import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.up.Distributor +import io.heckel.ntfy.util.safeLet class NotificationDispatcher(val context: Context, val repository: Repository) { private val notifier = NotificationService(context) @@ -18,8 +19,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { val muted = checkMuted(subscription) val notify = checkNotify(subscription, notification, muted) - val broadcast = subscription.upAppId == "" - val distribute = subscription.upAppId != "" + val broadcast = subscription.upAppId == null + val distribute = subscription.upAppId != null if (notify) { notifier.send(subscription, notification) } @@ -27,7 +28,9 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { broadcaster.send(subscription, notification, muted) } if (distribute) { - distributor.sendMessage(subscription.upAppId, subscription.upConnectorToken, notification.message) + safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken -> + distributor.sendMessage(appId, connectorToken, notification.message) + } } } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 71c36bb..125c766 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -7,6 +7,8 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.ui.SubscriberManager +import io.heckel.ntfy.util.randomString +import io.heckel.ntfy.util.topicUrlUp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -24,44 +26,62 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { Log.w(TAG, "Trying to register an app without packageName") return } - val baseUrl = context!!.getString(R.string.app_base_url) // FIXME - val topic = connectorToken // FIXME + val topic = "up" + randomString(TOPIC_LENGTH) + val endpoint = topicUrlUp(baseUrl, topic) val app = context!!.applicationContext as Application val repository = app.repository val distributor = Distributor(app) - val subscription = Subscription( - id = Random.nextLong(), - baseUrl = baseUrl, - topic = topic, - instant = true, - mutedUntil = 0, - upAppId = appId, - upConnectorToken = connectorToken, - totalCount = 0, - newCount = 0, - lastActive = Date().time/1000 - ) GlobalScope.launch(Dispatchers.IO) { + val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) + if (existingSubscription != null) { + distributor.sendRegistrationRefused(appId, connectorToken) + return@launch + } + val subscription = Subscription( + id = Random.nextLong(), + baseUrl = baseUrl, + topic = topic, + instant = true, // No Firebase, always instant! + mutedUntil = 0, + upAppId = appId, + upConnectorToken = connectorToken, + totalCount = 0, + newCount = 0, + lastActive = Date().time/1000 + ) repository.addSubscription(subscription) val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() - val subscriberManager = SubscriberManager(context!!) + val subscriberManager = SubscriberManager(app) subscriberManager.refreshService(subscriptionIdsWithInstantStatus) + distributor.sendEndpoint(appId, connectorToken, endpoint) } - distributor.sendEndpoint(appId, connectorToken) - // XXXXXXXXX } ACTION_UNREGISTER -> { val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" Log.d(TAG, "Unregister: connectorToken=$connectorToken") - // XXXXXXX - val distributor = Distributor(context!!) - distributor.sendUnregistered("org.unifiedpush.example", connectorToken) + val app = context!!.applicationContext as Application + val repository = app.repository + val distributor = Distributor(app) + GlobalScope.launch(Dispatchers.IO) { + val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) + if (existingSubscription == null) { + return@launch + } + repository.removeSubscription(existingSubscription.id) + val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() + val subscriberManager = SubscriberManager(app) + subscriberManager.refreshService(subscriptionIdsWithInstantStatus) + existingSubscription.upAppId?.let { appId -> + distributor.sendUnregistered(appId, connectorToken) + } + } } } } companion object { private const val TAG = "NtfyUpBroadcastRecv" + private const val TOPIC_LENGTH = 16 } } diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt index 975ab58..9bdaf7e 100644 --- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt +++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt @@ -2,35 +2,39 @@ package io.heckel.ntfy.up import android.content.Context import android.content.Intent -import io.heckel.ntfy.R -import io.heckel.ntfy.data.Repository -import io.heckel.ntfy.util.topicUrlUp class Distributor(val context: Context) { - fun sendMessage(app: String, token: String, message: String) { + fun sendMessage(app: String, connectorToken: String, message: String) { val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_MESSAGE - broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken) broadcastIntent.putExtra(EXTRA_MESSAGE, message) context.sendBroadcast(broadcastIntent) } - fun sendEndpoint(app: String, token: String) { - val appBaseUrl = context.getString(R.string.app_base_url) // FIXME + fun sendEndpoint(app: String, connectorToken: String, endpoint: String) { val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_NEW_ENDPOINT - broadcastIntent.putExtra(EXTRA_TOKEN, token) - broadcastIntent.putExtra(EXTRA_ENDPOINT, topicUrlUp(appBaseUrl, token)) + broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken) + broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint) context.sendBroadcast(broadcastIntent) } - fun sendUnregistered(app: String, token: String) { + fun sendUnregistered(app: String, connectorToken: String) { val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_UNREGISTERED - broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken) + context.sendBroadcast(broadcastIntent) + } + + fun sendRegistrationRefused(app: String, connectorToken: String) { + val broadcastIntent = Intent() + broadcastIntent.`package` = app + broadcastIntent.action = ACTION_REGISTRATION_REFUSED + broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken) context.sendBroadcast(broadcastIntent) } } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 1d8dcff..1b88747 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -5,6 +5,7 @@ import android.animation.ValueAnimator import android.view.Window import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Subscription +import java.security.SecureRandom import java.text.DateFormat import java.util.* @@ -102,3 +103,14 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) { } statusBarColorAnimation.start() } + +fun randomString(len: Int): String { + val random = SecureRandom() + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray() + return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("") +} + +// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785 +inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? { + return if (p1 != null && p2 != null) block(p1, p2) else null +} From 4efdce54efc03a772e0787d8b8f3f2d8b7d8d31f Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Thu, 30 Dec 2021 14:23:47 +0100 Subject: [PATCH 05/16] Works and is not super ugly --- .../main/java/io/heckel/ntfy/data/Database.kt | 4 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 16 +- .../java/io/heckel/ntfy/ui/MainAdapter.kt | 10 +- .../java/io/heckel/ntfy/ui/MainViewModel.kt | 9 +- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 149 +++++++++++------- .../java/io/heckel/ntfy/up/Distributor.kt | 9 ++ app/src/main/res/values/strings.xml | 5 +- 7 files changed, 130 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index ad87a96..e0187ce 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -34,8 +34,8 @@ data class SubscriptionWithMetadata( val topic: String, val instant: Boolean, val mutedUntil: Long, - val upAppId: String, - val upConnectorToken: String, + val upAppId: String?, + val upConnectorToken: String?, val totalCount: Int, val newCount: Int, val lastActive: Long diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 3a3e813..01222fa 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -283,8 +283,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc topic = topic, instant = instant, mutedUntil = 0, - upAppId = "", - upConnectorToken = "", + upAppId = null, + upConnectorToken = null, totalCount = 0, newCount = 0, lastActive = Date().time/1000 @@ -314,11 +314,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private fun onSubscriptionItemClick(subscription: Subscription) { if (actionMode != null) { handleActionModeClick(subscription) + } else if (subscription.upAppId != null) { // Not UnifiedPush + displayUnifiedPushToast(subscription) } else { startDetailView(subscription) } } + private fun displayUnifiedPushToast(subscription: Subscription) { + runOnUiThread { + val appId = subscription.upAppId ?: "" + val toastMessage = getString(R.string.main_unified_push_toast, appId) + Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show() + } + } + private fun onSubscriptionItemLongClick(subscription: Subscription) { if (actionMode == null) { beginActionMode(subscription) @@ -415,7 +425,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc val dialog = builder .setMessage(R.string.main_action_mode_delete_dialog_message) .setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ -> - adapter.selected.map { viewModel.remove(it) } + adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) } finishActionMode() } .setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ -> diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index adf52a5..dfbc016 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -55,7 +55,9 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon fun bind(subscription: Subscription) { this.subscription = subscription - var statusMessage = if (subscription.totalCount == 1) { + var statusMessage = if (subscription.upAppId != null) { + context.getString(R.string.main_item_status_unified_push, subscription.upAppId) + } else if (subscription.totalCount == 1) { context.getString(R.string.main_item_status_text_one, subscription.totalCount) } else { context.getString(R.string.main_item_status_text_not_one, subscription.totalCount) @@ -82,11 +84,11 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE - if (subscription.newCount > 0) { + if (subscription.upAppId != null || subscription.newCount == 0) { + newItemsView.visibility = View.GONE + } else { newItemsView.visibility = View.VISIBLE newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+" - } else { - newItemsView.visibility = View.GONE } itemView.setOnClickListener { onClick(subscription) } itemView.setOnLongClickListener { onLongClick(subscription); true } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt index f0b0275..9c24520 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -1,10 +1,12 @@ package io.heckel.ntfy.ui +import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import io.heckel.ntfy.data.* +import io.heckel.ntfy.up.Distributor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.collections.List @@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { repository.addSubscription(subscription) } - fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) { + fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) { + val subscription = repository.getSubscription(subscriptionId) ?: return@launch + if (subscription.upAppId != null && subscription.upConnectorToken != null) { + val distributor = Distributor(context) + distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken) + } repository.removeAllNotifications(subscriptionId) repository.removeSubscription(subscriptionId) } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 125c766..74fba12 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.util.Log import io.heckel.ntfy.R import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.ui.SubscriberManager import io.heckel.ntfy.util.randomString @@ -17,71 +18,99 @@ import kotlin.random.Random class BroadcastReceiver : android.content.BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - when (intent!!.action) { - ACTION_REGISTER -> { - val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: "" - val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" - Log.d(TAG, "Register: app=$appId, connectorToken=$connectorToken") - if (appId.isBlank()) { - Log.w(TAG, "Trying to register an app without packageName") - return - } - val baseUrl = context!!.getString(R.string.app_base_url) // FIXME - val topic = "up" + randomString(TOPIC_LENGTH) - val endpoint = topicUrlUp(baseUrl, topic) - val app = context!!.applicationContext as Application - val repository = app.repository - val distributor = Distributor(app) - GlobalScope.launch(Dispatchers.IO) { - val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) - if (existingSubscription != null) { - distributor.sendRegistrationRefused(appId, connectorToken) - return@launch - } - val subscription = Subscription( - id = Random.nextLong(), - baseUrl = baseUrl, - topic = topic, - instant = true, // No Firebase, always instant! - mutedUntil = 0, - upAppId = appId, - upConnectorToken = connectorToken, - totalCount = 0, - newCount = 0, - lastActive = Date().time/1000 - ) - repository.addSubscription(subscription) - val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() - val subscriberManager = SubscriberManager(app) - subscriberManager.refreshService(subscriptionIdsWithInstantStatus) - distributor.sendEndpoint(appId, connectorToken, endpoint) - } - } - ACTION_UNREGISTER -> { - val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: "" - Log.d(TAG, "Unregister: connectorToken=$connectorToken") - val app = context!!.applicationContext as Application - val repository = app.repository - val distributor = Distributor(app) - GlobalScope.launch(Dispatchers.IO) { - val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) - if (existingSubscription == null) { - return@launch - } - repository.removeSubscription(existingSubscription.id) - val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() - val subscriberManager = SubscriberManager(app) - subscriberManager.refreshService(subscriptionIdsWithInstantStatus) - existingSubscription.upAppId?.let { appId -> - distributor.sendUnregistered(appId, connectorToken) - } - } - } + if (context == null || intent == null) { + return } + when (intent.action) { + ACTION_REGISTER -> register(context, intent) + ACTION_UNREGISTER -> unregister(context, intent) + } + } + + private fun register(context: Context, intent: Intent) { + val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return + val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return + val app = context.applicationContext as Application + val repository = app.repository + val distributor = Distributor(app) + Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)") + if (appId.isBlank()) { + Log.w(TAG, "Refusing registration: empty application") + distributor.sendRegistrationRefused(appId, connectorToken) + return + } + GlobalScope.launch(Dispatchers.IO) { + val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) + if (existingSubscription != null) { + if (existingSubscription.upAppId == appId) { + val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic) + Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.") + distributor.sendEndpoint(appId, connectorToken, endpoint) + } else { + Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.") + distributor.sendRegistrationRefused(appId, connectorToken) + } + return@launch + } + val baseUrl = context.getString(R.string.app_base_url) // FIXME + val topic = UP_PREFIX + randomString(TOPIC_LENGTH) + val endpoint = topicUrlUp(baseUrl, topic) + val subscription = Subscription( + id = Random.nextLong(), + baseUrl = baseUrl, + topic = topic, + instant = true, // No Firebase, always instant! + mutedUntil = 0, + upAppId = appId, + upConnectorToken = connectorToken, + totalCount = 0, + newCount = 0, + lastActive = Date().time/1000 + ) + + // Add subscription + Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription") + repository.addSubscription(subscription) + distributor.sendEndpoint(appId, connectorToken, endpoint) + + // Refresh (and maybe start) foreground service + refreshSubscriberService(app, repository) + } + } + + private fun unregister(context: Context, intent: Intent) { + val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return + val app = context.applicationContext as Application + val repository = app.repository + val distributor = Distributor(app) + Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)") + GlobalScope.launch(Dispatchers.IO) { + val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken) + if (existingSubscription == null) { + Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.") + return@launch + } + + // Remove subscription + Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken") + repository.removeSubscription(existingSubscription.id) + existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) } + + // Refresh (and maybe stop) foreground service + refreshSubscriberService(app, repository) + } + } + + private fun refreshSubscriberService(context: Context, repository: Repository) { + Log.d(TAG, "Refreshing subscriber service") + val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() + val subscriberManager = SubscriberManager(context) + subscriberManager.refreshService(subscriptionIdsWithInstantStatus) } companion object { private const val TAG = "NtfyUpBroadcastRecv" + private const val UP_PREFIX = "up" private const val TOPIC_LENGTH = 16 } } diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt index 9bdaf7e..a4c1ad9 100644 --- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt +++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt @@ -2,9 +2,11 @@ package io.heckel.ntfy.up import android.content.Context import android.content.Intent +import android.util.Log class Distributor(val context: Context) { fun sendMessage(app: String, connectorToken: String, message: String) { + Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message") val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_MESSAGE @@ -14,6 +16,7 @@ class Distributor(val context: Context) { } fun sendEndpoint(app: String, connectorToken: String, endpoint: String) { + Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint") val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_NEW_ENDPOINT @@ -23,6 +26,7 @@ class Distributor(val context: Context) { } fun sendUnregistered(app: String, connectorToken: String) { + Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)") val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_UNREGISTERED @@ -31,10 +35,15 @@ class Distributor(val context: Context) { } fun sendRegistrationRefused(app: String, connectorToken: String) { + Log.d(TAG, "Sending REGISTRATION_REFUSED to $app (token=$connectorToken)") val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_REGISTRATION_REFUSED broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken) context.sendBroadcast(broadcastIntent) } + + companion object { + private const val TAG = "NtfyUpDistributor" + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fe54e6..f3fbb2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,7 @@ <string name="main_item_status_text_one">%1$d notification</string> <string name="main_item_status_text_not_one">%1$d notifications</string> <string name="main_item_status_reconnecting">reconnecting …</string> + <string name="main_item_status_unified_push">%1$s (UnifiedPush)</string> <string name="main_item_date_yesterday">Yesterday</string> <string name="main_add_button_description">Add subscription</string> <string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string> @@ -54,8 +55,8 @@ Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone. </string> - <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. - </string> + <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string> + <string name="main_unified_push_toast">Subscription is managed by %1$s via UnifiedPush</string> <!-- Add dialog --> <string name="add_dialog_title">Subscribe to topic</string> From 1cca29df564ef57cbd29aa83f27ae1cb334c250b Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Thu, 30 Dec 2021 17:00:27 +0100 Subject: [PATCH 06/16] Refactor subscriber manager (service starter) --- app/src/main/AndroidManifest.xml | 6 +-- .../heckel/ntfy/msg/NotificationDispatcher.kt | 2 +- .../{msg => service}/SubscriberConnection.kt | 3 +- .../{msg => service}/SubscriberService.kt | 38 +++---------- .../ntfy/service/SubscriberServiceManager.kt | 54 +++++++++++++++++++ .../java/io/heckel/ntfy/ui/DetailActivity.kt | 11 ++-- .../java/io/heckel/ntfy/ui/MainActivity.kt | 20 +++---- .../io/heckel/ntfy/ui/SubscriberManager.kt | 46 ---------------- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 16 ++---- .../heckel/ntfy/firebase/FirebaseService.kt | 1 + 10 files changed, 89 insertions(+), 108 deletions(-) rename app/src/main/java/io/heckel/ntfy/{msg => service}/SubscriberConnection.kt (98%) rename app/src/main/java/io/heckel/ntfy/{msg => service}/SubscriberService.kt (89%) create mode 100644 app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1beaca1..2fbf176 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,17 +44,17 @@ </activity> <!-- Subscriber foreground service for hosts other than ntfy.sh --> - <service android:name=".msg.SubscriberService"/> + <service android:name=".service.SubscriberService"/> <!-- Subscriber service restart on reboot --> - <receiver android:name=".msg.SubscriberService$BootStartReceiver" android:enabled="true"> + <receiver android:name=".service.SubscriberService$BootStartReceiver" android:enabled="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED"/> </intent-filter> </receiver> <!-- Subscriber service restart on destruction --> - <receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true" + <receiver android:name=".service.SubscriberService$AutoRestartReceiver" android:enabled="true" android:exported="false"/> <!-- Broadcast receiver to send messages via intents --> diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index aa251cd..18eb5b1 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -35,7 +35,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { - if (subscription.upAppId != "") { + if (subscription.upAppId != null) { return false } val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt similarity index 98% rename from app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt rename to app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt index e26cd68..e8ae782 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt @@ -1,9 +1,10 @@ -package io.heckel.ntfy.msg +package io.heckel.ntfy.service import android.util.Log import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.util.topicUrl import kotlinx.coroutines.* import okhttp3.Call diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt similarity index 89% rename from app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt rename to app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 90187bd..d000268 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -1,4 +1,4 @@ -package io.heckel.ntfy.msg +package io.heckel.ntfy.service import android.app.* import android.content.BroadcastReceiver @@ -11,8 +11,6 @@ import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig @@ -20,6 +18,8 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.msg.ApiService +import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.util.topicUrl import kotlinx.coroutines.* @@ -70,8 +70,8 @@ class SubscriberService : Service() { val action = intent.action Log.d(TAG, "using an intent with action $action") when (action) { - Actions.START.name -> startService() - Actions.STOP.name -> stopService() + Action.START.name -> startService() + Action.STOP.name -> stopService() else -> Log.e(TAG, "This should never happen. No action in the received intent") } } else { @@ -259,13 +259,7 @@ class SubscriberService : Service() { class BootStartReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.d(TAG, "BootStartReceiver: onReceive called") - if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) { - Intent(context, SubscriberService::class.java).also { - it.action = Actions.START.name - Log.d(TAG, "BootStartReceiver: Starting subscriber service") - ContextCompat.startForegroundService(context, it) - } - } + SubscriberServiceManager.refresh(context) } } @@ -276,27 +270,11 @@ class SubscriberService : Service() { class AutoRestartReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.d(TAG, "AutoRestartReceiver: onReceive called") - val workManager = WorkManager.getInstance(context) - val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build() - workManager.enqueue(startServiceRequest) + SubscriberServiceManager.refresh(context) } } - class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { - override fun doWork(): Result { - Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId()) - if (readServiceState(context) == ServiceState.STARTED) { - Intent(context, SubscriberService::class.java).also { - it.action = Actions.START.name - Log.d(TAG, "AutoRestartReceiver: Starting subscriber service") - ContextCompat.startForegroundService(context, it) - } - } - return Result.success() - } - } - - enum class Actions { + enum class Action { START, STOP } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt new file mode 100644 index 0000000..e3d6174 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -0,0 +1,54 @@ +package io.heckel.ntfy.service + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.work.* +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.up.BroadcastReceiver + +/** + * This class only manages the SubscriberService, i.e. it starts or stops it. + * It's used in multiple activities. + */ +class SubscriberServiceManager(private val context: Context) { + fun refresh() { + Log.d(TAG, "Enqueuing work to refresh subscriber service") + val workManager = WorkManager.getInstance(context) + val startServiceRequest = OneTimeWorkRequest.Builder(RefreshWorker::class.java).build() + workManager.enqueue(startServiceRequest) + } + + class RefreshWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + if (context.applicationContext !is Application) { + Log.d(TAG, "RefreshWorker: Failed, no application found (work ID: ${this.id})") + return Result.failure() + } + val app = context.applicationContext as Application + val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus() + val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size + val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP + val serviceState = SubscriberService.readServiceState(context) + if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) { + return Result.success() + } + Log.d(TAG, "RefreshWorker: Starting foreground service with action $action (work ID: ${this.id})") + Intent(context, SubscriberService::class.java).also { + it.action = action.name + ContextCompat.startForegroundService(context, it) + } + return Result.success() + } + } + + companion object { + const val TAG = "NtfySubscriberMgr" + + fun refresh(context: Context) { + val manager = SubscriberServiceManager(context) + manager.refresh() + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 9757c1f..4477a1b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -4,9 +4,6 @@ import android.app.AlertDialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.net.Uri import android.os.Bundle import android.text.Html import android.util.Log @@ -26,12 +23,12 @@ import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification -import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService +import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.formatDateShort import kotlinx.coroutines.* @@ -45,7 +42,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private val repository by lazy { (application as Application).repository } private val api = ApiService() private val messenger = FirebaseMessenger() - private var subscriberManager: SubscriberManager? = null // Context-dependent + private var serviceManager: SubscriberServiceManager? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent @@ -72,7 +69,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra Log.d(MainActivity.TAG, "Create $this") // Dependencies that depend on Context - subscriberManager = SubscriberManager(this) + serviceManager = SubscriberServiceManager(this) notifier = NotificationService(this) appBaseUrl = getString(R.string.app_base_url) @@ -149,7 +146,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra // React to changes in fast delivery setting repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { - subscriberManager?.refreshService(it) + serviceManager?.refresh() } // Mark this subscription as "open" so we don't receive notifications for it diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 01222fa..9829aa4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -23,6 +23,8 @@ import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.msg.* +import io.heckel.ntfy.service.SubscriberService +import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.formatDateShort import kotlinx.coroutines.Dispatchers @@ -52,7 +54,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private var actionMode: ActionMode? = null private var workManager: WorkManager? = null // Context-dependent private var dispatcher: NotificationDispatcher? = null // Context-dependent - private var subscriberManager: SubscriberManager? = null // Context-dependent + private var serviceManager: SubscriberServiceManager? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent override fun onCreate(savedInstanceState: Bundle?) { @@ -64,7 +66,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Dependencies that depend on Context workManager = WorkManager.getInstance(this) dispatcher = NotificationDispatcher(this, repository) - subscriberManager = SubscriberManager(this) + serviceManager = SubscriberServiceManager(this) appBaseUrl = getString(R.string.app_base_url) // Action bar @@ -105,7 +107,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // React to changes in instant delivery setting viewModel.listIdsWithInstantStatus().observe(this) { - subscriberManager?.refreshService(it) + serviceManager?.refresh() } // Create notification channels right away, so we can configure them immediately after installing the app @@ -116,7 +118,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Background things startPeriodicPollWorker() - startPeriodicAutoRestartWorker() + startPeriodicServiceRefreshWorker() } private fun startPeriodicPollWorker() { @@ -141,7 +143,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work) } - private fun startPeriodicAutoRestartWorker() { + private fun startPeriodicServiceRefreshWorker() { val workerVersion = repository.getAutoRestartWorkerVersion() val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) { Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy") @@ -151,12 +153,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION) ExistingPeriodicWorkPolicy.REPLACE } - val work = PeriodicWorkRequestBuilder<SubscriberService.AutoRestartWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES) + val work = PeriodicWorkRequestBuilder<SubscriberServiceManager.RefreshWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES) .addTag(SubscriberService.TAG) .addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC) .build() - Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes") - workManager!!.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work) + Log.d(TAG, "Auto restart worker: Scheduling period work every $MINIMUM_PERIODIC_WORKER_INTERVAL minutes") + workManager?.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -323,7 +325,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private fun displayUnifiedPushToast(subscription: Subscription) { runOnUiThread { - val appId = subscription.upAppId ?: "" + val appId = subscription.upAppId ?: return@runOnUiThread val toastMessage = getString(R.string.main_unified_push_toast, appId) Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt deleted file mode 100644 index 141096e..0000000 --- a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.heckel.ntfy.ui - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.lifecycle.lifecycleScope -import io.heckel.ntfy.msg.SubscriberService -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -/** - * This class only manages the SubscriberService, i.e. it starts or stops it. - * It's used in multiple activities. - */ -class SubscriberManager(private val context: Context) { - fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) { // Set<SubscriptionId -> IsInstant> - Log.d(MainActivity.TAG, "Triggering subscriber service refresh") - GlobalScope.launch(Dispatchers.IO) { - val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size - if (instantSubscriptions == 0) { - performActionOnSubscriberService(SubscriberService.Actions.STOP) - } else { - performActionOnSubscriberService(SubscriberService.Actions.START) - } - } - } - - private fun performActionOnSubscriberService(action: SubscriberService.Actions) { - val serviceState = SubscriberService.readServiceState(context) - if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) { - return - } - val intent = Intent(context, SubscriberService::class.java) - intent.action = action.name - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)") - context.startForegroundService(intent) - } else { - Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)") - context.startService(intent) - } - } -} diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 74fba12..345ca31 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -7,7 +7,7 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription -import io.heckel.ntfy.ui.SubscriberManager +import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.randomString import io.heckel.ntfy.util.topicUrlUp import kotlinx.coroutines.Dispatchers @@ -52,6 +52,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { } return@launch } + + // Add subscription val baseUrl = context.getString(R.string.app_base_url) // FIXME val topic = UP_PREFIX + randomString(TOPIC_LENGTH) val endpoint = topicUrlUp(baseUrl, topic) @@ -68,13 +70,12 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { lastActive = Date().time/1000 ) - // Add subscription Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription") repository.addSubscription(subscription) distributor.sendEndpoint(appId, connectorToken, endpoint) // Refresh (and maybe start) foreground service - refreshSubscriberService(app, repository) + SubscriberServiceManager.refresh(app) } } @@ -97,17 +98,10 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) } // Refresh (and maybe stop) foreground service - refreshSubscriberService(app, repository) + SubscriberServiceManager.refresh(context) } } - private fun refreshSubscriberService(context: Context, repository: Repository) { - Log.d(TAG, "Refreshing subscriber service") - val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus() - val subscriberManager = SubscriberManager(context) - subscriberManager.refreshService(subscriptionIdsWithInstantStatus) - } - companion object { private const val TAG = "NtfyUpBroadcastRecv" private const val UP_PREFIX = "up" diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 1df310c..a06ea2c 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -8,6 +8,7 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification import io.heckel.ntfy.msg.* +import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.util.toPriority import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob From 72393ec0af80a433b887047612b730f9e2d1cc50 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Thu, 30 Dec 2021 17:03:49 +0100 Subject: [PATCH 07/16] Update strings.xml --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3fbb2e..34f5ae9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,7 @@ messages via PUT or POST and you\'ll receive notifications on your phone. </string> <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string> - <string name="main_unified_push_toast">Subscription is managed by %1$s via UnifiedPush</string> + <string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string> <!-- Add dialog --> <string name="add_dialog_title">Subscribe to topic</string> From 2bc87013d59cb302b34c16f2a619713962c0248a Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Fri, 31 Dec 2021 01:34:25 +0100 Subject: [PATCH 08/16] Preferences dialog --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 40 ++++++--- .../java/io/heckel/ntfy/data/Repository.kt | 33 +++++-- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 1 - .../java/io/heckel/ntfy/ui/MainActivity.kt | 4 + .../io/heckel/ntfy/ui/SettingsActivity.kt | 88 +++++++++++++++++++ .../io/heckel/ntfy/up/BroadcastReceiver.kt | 12 +-- app/src/main/res/layout/activity_settings.xml | 9 ++ .../main/res/layout/fragment_main_item.xml | 7 +- .../preference_category_material_edited.xml | 68 ++++++++++++++ .../main/res/menu/menu_main_action_bar.xml | 1 + app/src/main/res/values/strings.xml | 38 ++++++-- app/src/main/res/xml/main_preferences.xml | 21 +++++ 13 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/preference_category_material_edited.xml create mode 100644 app/src/main/res/xml/main_preferences.xml diff --git a/app/build.gradle b/app/build.gradle index 24d40a4..d9138fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,6 +67,7 @@ dependencies { // WorkManager implementation "androidx.work:work-runtime-ktx:2.6.0" + implementation 'androidx.preference:preference:1.1.1' // Room (SQLite) def roomVersion = "2.3.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fbf176..bffc978 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> - <uses-permission android:name="android.permission.VIBRATE" /> + <uses-permission android:name="android.permission.VIBRATE"/> <application android:name=".app.Application" @@ -23,6 +23,7 @@ android:supportsRtl="true" android:theme="@style/AppTheme" android:usesCleartextTraffic="true"> + <!-- Main activity --> <activity android:name=".ui.MainActivity" @@ -43,32 +44,51 @@ android:value=".ui.MainActivity"/> </activity> + <!-- Settings activity --> + <activity + android:name=".ui.SettingsActivity" + android:parentActivityName=".ui.MainActivity"> + <meta-data + android:name="android.support.PARENT_ACTIVITY" + android:value=".ui.MainActivity"/> + </activity> + <!-- Subscriber foreground service for hosts other than ntfy.sh --> <service android:name=".service.SubscriberService"/> <!-- Subscriber service restart on reboot --> - <receiver android:name=".service.SubscriberService$BootStartReceiver" android:enabled="true"> + <receiver + android:name=".service.SubscriberService$BootStartReceiver" + android:enabled="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED"/> </intent-filter> </receiver> <!-- Subscriber service restart on destruction --> - <receiver android:name=".service.SubscriberService$AutoRestartReceiver" android:enabled="true" - android:exported="false"/> + <receiver + android:name=".service.SubscriberService$AutoRestartReceiver" + android:enabled="true" + android:exported="false"/> <!-- Broadcast receiver to send messages via intents --> - <receiver android:name=".msg.BroadcastService$BroadcastReceiver" android:enabled="true" android:exported="true"> + <receiver + android:name=".msg.BroadcastService$BroadcastReceiver" + android:enabled="true" + android:exported="true"> <intent-filter> <action android:name="io.heckel.ntfy.SEND_MESSAGE"/> </intent-filter> </receiver> - <!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md --> - <receiver android:name=".up.BroadcastReceiver" android:enabled="true" android:exported="true"> + <!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md --> + <receiver + android:name=".up.BroadcastReceiver" + android:enabled="true" + android:exported="true"> <intent-filter> - <action android:name="org.unifiedpush.android.distributor.REGISTER" /> - <action android:name="org.unifiedpush.android.distributor.UNREGISTER" /> + <action android:name="org.unifiedpush.android.distributor.REGISTER"/> + <action android:name="org.unifiedpush.android.distributor.UNREGISTER"/> </intent-filter> </receiver> @@ -80,7 +100,6 @@ <action android:name="com.google.firebase.MESSAGING_EVENT"/> </intent-filter> </service> - <meta-data android:name="firebase_analytics_collection_enabled" android:value="false"/> @@ -88,5 +107,4 @@ android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/ic_notification"/> </application> - </manifest> diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 1d1a2cc..59b0112 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -4,6 +4,7 @@ import android.content.SharedPreferences import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.* +import androidx.preference.PreferenceManager import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong @@ -142,12 +143,32 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri .apply() } - private suspend fun isMuted(subscriptionId: Long): Boolean { - if (isGlobalMuted()) { - return true + + fun getUnifiedPushEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default! + } + + fun setUnifiedPushEnabled(enabled: Boolean) { + sharedPrefs.edit() + .putBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, enabled) + .apply() + } + + fun getUnifiedPushBaseUrl(): String? { + return sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) + } + + fun setUnifiedPushBaseUrl(baseUrl: String) { + if (baseUrl == "") { + sharedPrefs + .edit() + .remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) + .apply() + } else { + sharedPrefs.edit() + .putString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, baseUrl) + .apply() } - val s = getSubscription(subscriptionId) ?: return true - return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000) } fun isGlobalMuted(): Boolean { @@ -242,6 +263,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion" const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" + const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled" + const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" private const val TAG = "NtfyRepository" private var instance: Repository? = null diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 4477a1b..4113a46 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -420,7 +420,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra val formattedDate = formatDateShort(subscriptionMutedUntil) notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) } - } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 9829aa4..bb971f3 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -232,6 +232,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc onNotificationSettingsClick(enable = true) true } + R.id.main_menu_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + true + } R.id.main_menu_source -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url)))) true diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt new file mode 100644 index 0000000..ae87e6a --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -0,0 +1,88 @@ +package io.heckel.ntfy.ui + +import android.os.Bundle +import android.text.TextUtils +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.* +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.R +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Repository + +class SettingsActivity : AppCompatActivity() { + private val repository by lazy { (application as Application).repository } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + Log.d(MainActivity.TAG, "Create $this") + + if (savedInstanceState == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.settings_layout, SettingsFragment(repository)) + .commit() + } + + // Action bar + title = getString(R.string.settings_title) + + // Show 'Back' button + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + class SettingsFragment(val repository: Repository) : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.main_preferences, rootKey) + + // UnifiedPush Enabled + val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return + val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId) + upEnabled?.isChecked = repository.getUnifiedPushEnabled() + upEnabled?.preferenceDataStore = object : PreferenceDataStore() { + override fun putBoolean(key: String?, value: Boolean) { + repository.setUnifiedPushEnabled(value) + } + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return repository.getUnifiedPushEnabled() + } + } + upEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref -> + if (pref.isChecked) { + getString(R.string.settings_unified_push_enabled_summary_on) + } else { + getString(R.string.settings_unified_push_enabled_summary_off) + } + } + + // UnifiedPush Base URL + val appBaseUrl = context?.getString(R.string.app_base_url) ?: return + val upBaseUrlPrefId = context?.getString(R.string.pref_unified_push_base_url) ?: return + val upBaseUrl: EditTextPreference? = findPreference(upBaseUrlPrefId) + upBaseUrl?.text = repository.getUnifiedPushBaseUrl() ?: "" + upBaseUrl?.preferenceDataStore = object : PreferenceDataStore() { + override fun putString(key: String, value: String?) { + val baseUrl = value ?: return + repository.setUnifiedPushBaseUrl(baseUrl) + } + override fun getString(key: String, defValue: String?): String? { + return repository.getUnifiedPushBaseUrl() + } + } + upBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref -> + if (TextUtils.isEmpty(pref.text)) { + getString(R.string.settings_unified_push_base_url_default_summary, appBaseUrl) + } else { + pref.text + } + } + + // Version + val versionPrefId = context?.getString(R.string.pref_version) ?: return + val versionPref: Preference? = findPreference(versionPrefId) + versionPref?.summary = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR) + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 345ca31..8e07ee0 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -3,9 +3,9 @@ package io.heckel.ntfy.up import android.content.Context import android.content.Intent import android.util.Log +import androidx.preference.PreferenceManager import io.heckel.ntfy.R import io.heckel.ntfy.app.Application -import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.randomString @@ -34,8 +34,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { val repository = app.repository val distributor = Distributor(app) Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)") - if (appId.isBlank()) { - Log.w(TAG, "Refusing registration: empty application") + if (!repository.getUnifiedPushEnabled() || appId.isBlank()) { + Log.w(TAG, "Refusing registration: UnifiedPush disabled or empty application") distributor.sendRegistrationRefused(appId, connectorToken) return } @@ -54,8 +54,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { } // Add subscription - val baseUrl = context.getString(R.string.app_base_url) // FIXME - val topic = UP_PREFIX + randomString(TOPIC_LENGTH) + val baseUrl = repository.getUnifiedPushBaseUrl() ?: context.getString(R.string.app_base_url) + val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH) val endpoint = topicUrlUp(baseUrl, topic) val subscription = Subscription( id = Random.nextLong(), @@ -105,6 +105,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { companion object { private const val TAG = "NtfyUpBroadcastRecv" private const val UP_PREFIX = "up" - private const val TOPIC_LENGTH = 16 + private const val TOPIC_RANDOM_ID_LENGTH = 12 } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..f02c645 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,9 @@ +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <FrameLayout + android:id="@+id/settings_layout" + android:layout_width="match_parent" + android:layout_height="match_parent"/> +</LinearLayout> diff --git a/app/src/main/res/layout/fragment_main_item.xml b/app/src/main/res/layout/fragment_main_item.xml index d46449c..050083d 100644 --- a/app/src/main/res/layout/fragment_main_item.xml +++ b/app/src/main/res/layout/fragment_main_item.xml @@ -24,12 +24,13 @@ android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp" app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/> <TextView - android:text="89 notifications" - android:layout_width="wrap_content" + android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush" + android:layout_width="0dp" android:layout_height="wrap_content" android:id="@+id/main_item_status" app:layout_constraintStart_toStartOf="@+id/main_item_text" app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent" - android:layout_marginBottom="10dp"/> + android:layout_marginBottom="10dp" app:layout_constrainedWidth="true" + app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/> <ImageView android:layout_width="20dp" android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp" diff --git a/app/src/main/res/layout/preference_category_material_edited.xml b/app/src/main/res/layout/preference_category_material_edited.xml new file mode 100644 index 0000000..f0a481a --- /dev/null +++ b/app/src/main/res/layout/preference_category_material_edited.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + This is a slightly edited copy of the original Android project layout + to make wrapping the summary line work. + + ~ Copyright (C) 2015 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingRight="?android:attr/listPreferredItemPaddingRight" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:background="?android:attr/selectableItemBackground" + android:baselineAligned="false" + android:layout_marginTop="16dp" + android:gravity="center_vertical"> + + <include layout="@layout/image_frame"/> + + <RelativeLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:paddingTop="8dp" + android:paddingBottom="8dp"> + + <TextView + android:id="@android:id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="start" + android:textAlignment="viewStart" + style="@style/PreferenceCategoryTitleTextStyle"/> + + <!-- EDITED singleLine --> + <TextView + android:id="@android:id/summary" + android:ellipsize="end" + android:singleLine="false" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@android:id/title" + android:layout_alignLeft="@android:id/title" + android:layout_alignStart="@android:id/title" + android:layout_gravity="start" + android:textAlignment="viewStart" + android:textColor="?android:attr/textColorSecondary" + android:maxLines="10" + style="@style/PreferenceSummaryTextStyle"/> + + </RelativeLayout> +</LinearLayout> diff --git a/app/src/main/res/menu/menu_main_action_bar.xml b/app/src/main/res/menu/menu_main_action_bar.xml index 89c763b..bd213aa 100644 --- a/app/src/main/res/menu/menu_main_action_bar.xml +++ b/app/src/main/res/menu/menu_main_action_bar.xml @@ -5,6 +5,7 @@ app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/> <item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever" app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/> + <item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/> <item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/> <item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/> </menu> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34f5ae9..9a8e887 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,8 @@ <string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string> <string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string> <string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string> - <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics</string> + <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics + </string> <string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string> <string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string> @@ -30,6 +31,7 @@ <string name="main_menu_notifications_enabled">Notifications enabled</string> <string name="main_menu_notifications_disabled_forever">Notifications disabled</string> <string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string> + <string name="main_menu_settings_title">Settings</string> <string name="main_menu_source_title">Report a bug</string> <string name="main_menu_source_url">https://heckel.io/ntfy-android</string> <string name="main_menu_website_title">Visit ntfy.sh</string> @@ -55,7 +57,8 @@ Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone. </string> - <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string> + <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. + </string> <string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string> <!-- Add dialog --> @@ -81,10 +84,13 @@ <!-- Detail activity --> <string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string> <string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string> - <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string> + <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL. + </string> <string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string> - <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string> - <string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?</string> + <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. + </string> + <string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic? + </string> <string name="detail_clear_dialog_permanently_delete">Permanently delete</string> <string name="detail_clear_dialog_cancel">Cancel</string> <string name="detail_delete_dialog_message"> @@ -94,7 +100,9 @@ <string name="detail_delete_dialog_permanently_delete">Permanently delete</string> <string name="detail_delete_dialog_cancel">Cancel</string> <string name="detail_test_title">Test: You can set a title if you like</string> - <string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d. If you send another one, it may look different.</string> + <string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d. + If you send another one, it may look different. + </string> <string name="detail_test_message_error">Could not send test message: %1$s</string> <string name="detail_copied_to_clipboard_message">Copied to clipboard</string> <string name="detail_instant_delivery_enabled">Instant delivery enabled</string> @@ -136,4 +144,22 @@ <string name="notification_dialog_8h">8 hours</string> <string name="notification_dialog_tomorrow">Until tomorrow</string> <string name="notification_dialog_forever">Forever</string> + + <!-- Settings --> + <string name="settings_title">Settings</string> + <string name="settings_unified_push_header">UnifiedPush</string> + <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string> + <string name="settings_unified_push_enabled_title">Enabled</string> + <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string> + <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string> + <string name="settings_unified_push_base_url_title">Server URL</string> + <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string> + <string name="settings_about_header">About</string> + <string name="settings_about_version">Version</string> + <string name="settings_about_version_format">ntfy %1$s (%2$s)</string> + + <!-- Preferences IDs --> + <string name="pref_unified_push_enabled">UnifiedPushEnabled</string> + <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string> + <string name="pref_version">Version</string> </resources> diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml new file mode 100644 index 0000000..30a196c --- /dev/null +++ b/app/src/main/res/xml/main_preferences.xml @@ -0,0 +1,21 @@ +<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" + app:title="@string/settings_title"> + <PreferenceCategory + app:title="@string/settings_unified_push_header" + app:summary="@string/settings_unified_push_header_summary" + app:layout="@layout/preference_category_material_edited"> + <SwitchPreference + app:key="@string/pref_unified_push_enabled" + app:title="@string/settings_unified_push_enabled_title" + app:enabled="true"/> + <EditTextPreference + app:key="@string/pref_unified_push_base_url" + app:title="@string/settings_unified_push_base_url_title" + app:dependency="@string/pref_unified_push_enabled"/> + </PreferenceCategory> + <PreferenceCategory app:title="@string/settings_about_header"> + <Preference + app:key="@string/pref_version" + app:title="@string/settings_about_version"/> + </PreferenceCategory> +</PreferenceScreen> From bec263d1c818191ed106baf11e0cfc80f68481b7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Fri, 31 Dec 2021 02:00:08 +0100 Subject: [PATCH 09/16] Tiny changes --- .../java/io/heckel/ntfy/msg/NotificationDispatcher.kt | 8 ++++---- app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt | 4 +--- app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt | 4 +--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 18eb5b1..57cab2d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -7,6 +7,10 @@ import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.up.Distributor import io.heckel.ntfy.util.safeLet +/** + * The notification dispatcher figures out what to do with a notification. + * It may display a notification, send out a broadcast, or forward via UnifiedPush. + */ class NotificationDispatcher(val context: Context, val repository: Repository) { private val notifier = NotificationService(context) private val broadcaster = BroadcastService(context) @@ -48,8 +52,4 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000) } - - companion object { - private const val TAG = "NtfyNotificationDispatcher" - } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 4113a46..599d5fb 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -42,7 +42,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private val repository by lazy { (application as Application).repository } private val api = ApiService() private val messenger = FirebaseMessenger() - private var serviceManager: SubscriberServiceManager? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent @@ -69,7 +68,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra Log.d(MainActivity.TAG, "Create $this") // Dependencies that depend on Context - serviceManager = SubscriberServiceManager(this) notifier = NotificationService(this) appBaseUrl = getString(R.string.app_base_url) @@ -146,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra // React to changes in fast delivery setting repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { - serviceManager?.refresh() + SubscriberServiceManager.refresh(this) } // Mark this subscription as "open" so we don't receive notifications for it diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index bb971f3..8297a95 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -54,7 +54,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private var actionMode: ActionMode? = null private var workManager: WorkManager? = null // Context-dependent private var dispatcher: NotificationDispatcher? = null // Context-dependent - private var serviceManager: SubscriberServiceManager? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent override fun onCreate(savedInstanceState: Bundle?) { @@ -66,7 +65,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Dependencies that depend on Context workManager = WorkManager.getInstance(this) dispatcher = NotificationDispatcher(this, repository) - serviceManager = SubscriberServiceManager(this) appBaseUrl = getString(R.string.app_base_url) // Action bar @@ -107,7 +105,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // React to changes in instant delivery setting viewModel.listIdsWithInstantStatus().observe(this) { - serviceManager?.refresh() + SubscriberServiceManager.refresh(this) } // Create notification channels right away, so we can configure them immediately after installing the app From 496bdcd2856929ad2f3976f49e42b95af8dac9b0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Fri, 31 Dec 2021 15:30:49 +0100 Subject: [PATCH 10/16] Comments --- .../heckel/ntfy/msg/NotificationDispatcher.kt | 4 ++-- .../ntfy/service/SubscriberServiceManager.kt | 9 ++++++++ .../io/heckel/ntfy/ui/SettingsActivity.kt | 22 ++++++++++++++++++- .../io/heckel/ntfy/up/BroadcastReceiver.kt | 5 ++++- .../java/io/heckel/ntfy/up/Distributor.kt | 4 ++++ app/src/main/java/io/heckel/ntfy/util/Util.kt | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 57cab2d..f2c13d0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -23,8 +23,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { val muted = checkMuted(subscription) val notify = checkNotify(subscription, notification, muted) - val broadcast = subscription.upAppId == null - val distribute = subscription.upAppId != null + val broadcast = subscription.upAppId == null // Never broadcast for UnifiedPush + val distribute = subscription.upAppId != null // Only distribute for UnifiedPush subscriptions if (notify) { notifier.send(subscription, notification) } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index e3d6174..a4ecfbb 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -11,6 +11,11 @@ import io.heckel.ntfy.up.BroadcastReceiver /** * This class only manages the SubscriberService, i.e. it starts or stops it. * It's used in multiple activities. + * + * We are starting the service via a worker and not directly because since Android 7 + * (but officially since Lollipop!), any process called by a BroadcastReceiver + * (only manifest-declared receiver) is run at low priority and hence eventually + * killed by Android. */ class SubscriberServiceManager(private val context: Context) { fun refresh() { @@ -20,6 +25,10 @@ class SubscriberServiceManager(private val context: Context) { workManager.enqueue(startServiceRequest) } + /** + * Starts or stops the foreground service by figuring out how many instant delivery subscriptions + * exist. If there's > 0, then we need a foreground service. + */ class RefreshWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { override fun doWork(): Result { if (context.applicationContext !is Application) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index ae87e6a..7a3992d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -1,10 +1,15 @@ package io.heckel.ntfy.ui +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.os.Bundle import android.text.TextUtils import android.util.Log +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.preference.* +import androidx.preference.Preference.OnPreferenceClickListener import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.app.Application @@ -37,6 +42,10 @@ class SettingsActivity : AppCompatActivity() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.main_preferences, rootKey) + // Important note: We do not use the default shared prefs to store settings. Every + // preferenceDataStore is overridden to use the repository. This is convenient, because + // everybody has access to the repository. + // UnifiedPush Enabled val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId) @@ -82,7 +91,18 @@ class SettingsActivity : AppCompatActivity() { // Version val versionPrefId = context?.getString(R.string.pref_version) ?: return val versionPref: Preference? = findPreference(versionPrefId) - versionPref?.summary = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR) + val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR) + versionPref?.summary = version + versionPref?.onPreferenceClickListener = OnPreferenceClickListener { + val context = context ?: return@OnPreferenceClickListener false + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("app version", version) + clipboard.setPrimaryClip(clip) + Toast + .makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG) + .show() + true + } } } } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 8e07ee0..2a7d64c 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -16,6 +16,10 @@ import kotlinx.coroutines.launch import java.util.* import kotlin.random.Random +/** + * This is the UnifiedPush broadcast receiver to handle the distributor actions REGISTER and UNREGISTER. + * See https://unifiedpush.org/spec/android/ for details. + */ class BroadcastReceiver : android.content.BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (context == null || intent == null) { @@ -69,7 +73,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { newCount = 0, lastActive = Date().time/1000 ) - Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription") repository.addSubscription(subscription) distributor.sendEndpoint(appId, connectorToken, endpoint) diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt index a4c1ad9..38273a9 100644 --- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt +++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt @@ -4,6 +4,10 @@ import android.content.Context import android.content.Intent import android.util.Log +/** + * This is the UnifiedPush distributor, an amalgamation of messages to be sent as part of the spec. + * See https://unifiedpush.org/spec/android/ for details. + */ class Distributor(val context: Context) { fun sendMessage(app: String, connectorToken: String, message: String) { Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message") diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 1b88747..23c3747 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -104,6 +104,7 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) { statusBarColorAnimation.start() } +// Generates a (cryptographically secure) random string of a certain length fun randomString(len: Int): String { val random = SecureRandom() val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a8e887..d209226 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -157,6 +157,7 @@ <string name="settings_about_header">About</string> <string name="settings_about_version">Version</string> <string name="settings_about_version_format">ntfy %1$s (%2$s)</string> + <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string> <!-- Preferences IDs --> <string name="pref_unified_push_enabled">UnifiedPushEnabled</string> From f527ee734394f420490d1148dc92522af7f205df Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 00:12:36 +0100 Subject: [PATCH 11/16] Migration fix --- app/src/main/java/io/heckel/ntfy/data/Database.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index e0187ce..94a56b9 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -108,7 +108,7 @@ abstract class Database : RoomDatabase() { } } - private val MIGRATION_4_5 = object : Migration(3, 4) { + private val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT") From 9cc6ffc32ee95f36857fe1f9ec13325fa26ffa6a Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 00:21:59 +0100 Subject: [PATCH 12/16] Add index during migration --- app/src/main/java/io/heckel/ntfy/data/Database.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 94a56b9..8fc2325 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -112,6 +112,7 @@ abstract class Database : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT") + db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)") } } } From 1c6dd84543246074530c28545aa68f53f0f4e56a Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 00:25:50 +0100 Subject: [PATCH 13/16] Bump version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index d9138fa..a529e64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { minSdkVersion 21 targetSdkVersion 30 - versionCode 12 - versionName "1.4.2" + versionCode 13 + versionName "1.5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" From 1ce42048b59622e7d5cff8abc38e5ee70efcd4b1 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 13:42:00 +0100 Subject: [PATCH 14/16] MutedUntil setting in Settings dialog --- .../java/io/heckel/ntfy/ui/MainActivity.kt | 2 +- .../io/heckel/ntfy/ui/NotificationFragment.kt | 10 +++-- .../io/heckel/ntfy/ui/SettingsActivity.kt | 38 ++++++++++++++++++- app/src/main/res/values/strings.xml | 14 +++++-- app/src/main/res/xml/main_preferences.xml | 11 +++++- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 8297a95..b571331 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -169,7 +169,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc private fun startNotificationMutedChecker() { lifecycleScope.launch(Dispatchers.IO) { - delay(1000) // Just to be sure we've initialized all the things, we wait a bit ... + delay(5000) // Just to be sure we've initialized all the things, we wait a bit ... while (isActive) { Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp") diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt index a09dd8f..a0f3bb2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt @@ -4,6 +4,7 @@ import android.app.AlertDialog import android.app.Dialog import android.content.Context import android.os.Bundle +import android.util.Log import android.widget.RadioButton import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope @@ -16,8 +17,9 @@ import kotlinx.coroutines.launch import java.util.* class NotificationFragment : DialogFragment() { + var settingsListener: NotificationSettingsListener? = null + private lateinit var repository: Repository - private lateinit var settingsListener: NotificationSettingsListener private lateinit var muteFor30minButton: RadioButton private lateinit var muteFor1hButton: RadioButton private lateinit var muteFor2hButton: RadioButton @@ -31,7 +33,9 @@ class NotificationFragment : DialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - settingsListener = activity as NotificationSettingsListener + if (settingsListener == null) { + settingsListener = activity as NotificationSettingsListener + } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -85,7 +89,7 @@ class NotificationFragment : DialogFragment() { private fun onClick(mutedUntilTimestamp: Long) { lifecycleScope.launch(Dispatchers.Main) { delay(150) // Another hack: Let the animation finish before dismissing the window - settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp) + settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp) dismiss() } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index 7a3992d..c1a709d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -8,12 +8,14 @@ import android.text.TextUtils import android.util.Log import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager import androidx.preference.* import androidx.preference.Preference.OnPreferenceClickListener import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Repository +import io.heckel.ntfy.util.formatDateShort class SettingsActivity : AppCompatActivity() { private val repository by lazy { (application as Application).repository } @@ -27,7 +29,7 @@ class SettingsActivity : AppCompatActivity() { if (savedInstanceState == null) { supportFragmentManager .beginTransaction() - .replace(R.id.settings_layout, SettingsFragment(repository)) + .replace(R.id.settings_layout, SettingsFragment(repository, supportFragmentManager)) .commit() } @@ -38,7 +40,7 @@ class SettingsActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) } - class SettingsFragment(val repository: Repository) : PreferenceFragmentCompat() { + class SettingsFragment(val repository: Repository, private val supportFragmentManager: FragmentManager) : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.main_preferences, rootKey) @@ -46,6 +48,38 @@ class SettingsActivity : AppCompatActivity() { // preferenceDataStore is overridden to use the repository. This is convenient, because // everybody has access to the repository. + // Notifications muted until (global) + val mutedUntilPrefId = context?.getString(R.string.pref_notifications_muted_until) ?: return + val mutedUntilSummary = { s: Long -> + when (s) { + 0L -> getString(R.string.settings_notifications_muted_until_enabled) + 1L -> getString(R.string.settings_notifications_muted_until_disabled_forever) + else -> { + val formattedDate = formatDateShort(s) + getString(R.string.settings_notifications_muted_until_disabled_until, formattedDate) + } + } + } + val mutedUntil: Preference? = findPreference(mutedUntilPrefId) + mutedUntil?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting + mutedUntil?.summary = mutedUntilSummary(repository.getGlobalMutedUntil()) + mutedUntil?.onPreferenceClickListener = OnPreferenceClickListener { + if (repository.getGlobalMutedUntil() > 0) { + repository.setGlobalMutedUntil(0) + mutedUntil?.summary = mutedUntilSummary(0) + } else { + val notificationFragment = NotificationFragment() + notificationFragment.settingsListener = object : NotificationFragment.NotificationSettingsListener { + override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { + repository.setGlobalMutedUntil(mutedUntilTimestamp) + mutedUntil?.summary = mutedUntilSummary(mutedUntilTimestamp) + } + } + notificationFragment.show(supportFragmentManager, NotificationFragment.TAG) + } + true + } + // UnifiedPush Enabled val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d209226..4d97b73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,8 +16,7 @@ <string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string> <string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string> <string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string> - <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics - </string> + <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics</string> <string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string> <string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string> @@ -147,19 +146,26 @@ <!-- Settings --> <string name="settings_title">Settings</string> + <string name="settings_notifications_header">Notifications</string> + <string name="settings_notifications_header_summary">General settings for all subscribed topics</string> + <string name="settings_notifications_muted_until_title">Pause notifications</string> + <string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string> + <string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string> + <string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string> <string name="settings_unified_push_header">UnifiedPush</string> <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string> - <string name="settings_unified_push_enabled_title">Enabled</string> + <string name="settings_unified_push_enabled_title">Enable distributor</string> <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string> <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string> <string name="settings_unified_push_base_url_title">Server URL</string> <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string> <string name="settings_about_header">About</string> - <string name="settings_about_version">Version</string> + <string name="settings_about_version_title">Version</string> <string name="settings_about_version_format">ntfy %1$s (%2$s)</string> <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string> <!-- Preferences IDs --> + <string name="pref_notifications_muted_until">MutedUntil</string> <string name="pref_unified_push_enabled">UnifiedPushEnabled</string> <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string> <string name="pref_version">Version</string> diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index 30a196c..c404af4 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -1,5 +1,14 @@ <PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:android="http://schemas.android.com/apk/res/android" app:title="@string/settings_title"> + <PreferenceCategory + app:title="@string/settings_notifications_header" + app:summary="@string/settings_notifications_header_summary" + app:layout="@layout/preference_category_material_edited"> + <Preference + app:key="@string/pref_notifications_muted_until" + app:title="@string/settings_notifications_muted_until_title"/> + </PreferenceCategory> <PreferenceCategory app:title="@string/settings_unified_push_header" app:summary="@string/settings_unified_push_header_summary" @@ -16,6 +25,6 @@ <PreferenceCategory app:title="@string/settings_about_header"> <Preference app:key="@string/pref_version" - app:title="@string/settings_about_version"/> + app:title="@string/settings_about_version_title"/> </PreferenceCategory> </PreferenceScreen> From d10344549f4f5e8aa14deabc4070666cbd8a336e Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 16:56:18 +0100 Subject: [PATCH 15/16] Add min priority and broadcast enabled switch, fix #57 --- .../java/io/heckel/ntfy/data/Repository.kt | 29 +++++++++- .../io/heckel/ntfy/msg/BroadcastService.kt | 53 +++++++++++------- .../heckel/ntfy/msg/NotificationDispatcher.kt | 27 +++++++-- .../java/io/heckel/ntfy/ui/MainActivity.kt | 3 +- .../java/io/heckel/ntfy/ui/MainAdapter.kt | 14 +++-- .../io/heckel/ntfy/ui/SettingsActivity.kt | 56 +++++++++++++++++-- app/src/main/java/io/heckel/ntfy/util/Util.kt | 11 ++++ app/src/main/res/values/strings.xml | 28 +++++++--- app/src/main/res/values/values.xml | 17 ++++++ app/src/main/res/xml/main_preferences.xml | 23 ++++++-- 10 files changed, 210 insertions(+), 51 deletions(-) create mode 100644 app/src/main/res/values/values.xml diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 59b0112..0907ca4 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -143,9 +143,34 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri .apply() } + fun setMinPriority(minPriority: Int) { + if (minPriority <= 1) { + sharedPrefs.edit() + .remove(SHARED_PREFS_MIN_PRIORITY) + .apply() + } else { + sharedPrefs.edit() + .putInt(SHARED_PREFS_MIN_PRIORITY, minPriority) + .apply() + } + } + + fun getMinPriority(): Int { + return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, 1) // 1/low means all priorities + } + + fun getBroadcastEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default + } + + fun setBroadcastEnabled(enabled: Boolean) { + sharedPrefs.edit() + .putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled) + .apply() + } fun getUnifiedPushEnabled(): Boolean { - return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default! + return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default } fun setUnifiedPushEnabled(enabled: Boolean) { @@ -263,6 +288,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion" const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" + const val SHARED_PREFS_MIN_PRIORITY = "MinPriority" + const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled" const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled" const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index bc32d28..9d4e43f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -13,8 +13,8 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch /** - * The broadcast service is responsible for sending and receiving broadcasted intents - * in order to facilitate taks app integrations. + * The broadcast service is responsible for sending and receiving broadcast intents + * in order to facilitate tasks app integrations. */ class BroadcastService(private val ctx: Context) { fun send(subscription: Subscription, notification: Notification, muted: Boolean) { @@ -36,6 +36,10 @@ class BroadcastService(private val ctx: Context) { ctx.sendBroadcast(intent) } + /** + * This receiver is triggered when the SEND_MESSAGE intent is received. + * See AndroidManifest.xml for details. + */ class BroadcastReceiver : android.content.BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.d(TAG, "Broadcast received: $intent") @@ -46,24 +50,20 @@ class BroadcastService(private val ctx: Context) { private fun send(ctx: Context, intent: Intent) { val api = ApiService() - val baseUrl = intent.getStringExtra("base_url") ?: ctx.getString(R.string.app_base_url) - val topic = intent.getStringExtra("topic") ?: return - val message = intent.getStringExtra("message") ?: return - val title = intent.getStringExtra("title") ?: "" - val tags = intent.getStringExtra("tags") ?: "" - val priority = if (intent.getStringExtra("priority") != null) { - when (intent.getStringExtra("priority")) { - "min", "1" -> 1 - "low", "2" -> 2 - "default", "3" -> 3 - "high", "4" -> 4 - "urgent", "max", "5" -> 5 - else -> 0 - } - } else { - intent.getIntExtra("priority", 0) + val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url) + val topic = getStringExtra(intent, "topic") ?: return + val message = getStringExtra(intent, "message") ?: return + val title = getStringExtra(intent, "title") ?: "" + val tags = getStringExtra(intent,"tags") ?: "" + val priority = when (getStringExtra(intent, "priority")) { + "min", "1" -> 1 + "low", "2" -> 2 + "default", "3" -> 3 + "high", "4" -> 4 + "urgent", "max", "5" -> 5 + else -> 0 } - val delay = intent.getStringExtra("delay") ?: "" + val delay = getStringExtra(intent,"delay") ?: "" GlobalScope.launch(Dispatchers.IO) { api.publish( baseUrl = baseUrl, @@ -76,11 +76,26 @@ class BroadcastService(private val ctx: Context) { ) } } + + /** + * Gets an extra as a String value, even if the extra may be an int or a long. + */ + private fun getStringExtra(intent: Intent, name: String): String? { + if (intent.getStringExtra(name) != null) { + return intent.getStringExtra(name) + } else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) { + return intent.getIntExtra(name, DOES_NOT_EXIST).toString() + } else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) { + return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString() + } + return null + } } companion object { private const val TAG = "NtfyBroadcastService" private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED" private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" // If changed, change in manifest too! + private const val DOES_NOT_EXIST = -2586000 } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index f2c13d0..c4cec51 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -21,10 +21,10 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } fun dispatch(subscription: Subscription, notification: Notification) { - val muted = checkMuted(subscription) - val notify = checkNotify(subscription, notification, muted) - val broadcast = subscription.upAppId == null // Never broadcast for UnifiedPush - val distribute = subscription.upAppId != null // Only distribute for UnifiedPush subscriptions + val muted = getMuted(subscription) + val notify = shouldNotify(subscription, notification, muted) + val broadcast = shouldBroadcast(subscription) + val distribute = shouldDistribute(subscription) if (notify) { notifier.send(subscription, notification) } @@ -38,15 +38,30 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } - private fun checkNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { + private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { if (subscription.upAppId != null) { return false } + val priority = if (notification.priority > 0) notification.priority else 3 + if (priority < repository.getMinPriority()) { + return false + } val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId return !detailsVisible && !muted } - private fun checkMuted(subscription: Subscription): Boolean { + private fun shouldBroadcast(subscription: Subscription): Boolean { + if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions + return false + } + return repository.getBroadcastEnabled() + } + + private fun shouldDistribute(subscription: Subscription): Boolean { + return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions + } + + private fun getMuted(subscription: Subscription): Boolean { if (repository.isGlobalMuted()) { return true } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index b571331..241c2e2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -87,7 +87,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) } mainList = findViewById(R.id.main_subscriptions_list) - adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick) + adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick) mainList.adapter = adapter viewModel.list().observe(this) { @@ -261,6 +261,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc repository.setGlobalMutedUntil(mutedUntilTimestamp) showHideNotificationMenuItems() runOnUiThread { + redrawList() // Update the "muted until" icons when (mutedUntilTimestamp) { 0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show() 1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index dfbc016..6a361ed 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -10,12 +10,13 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import io.heckel.ntfy.R import io.heckel.ntfy.data.ConnectionState +import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.util.topicShortUrl import java.text.DateFormat import java.util.* -class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) : +class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) : ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) { val selected = mutableSetOf<Long>() // Subscription IDs @@ -23,7 +24,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.fragment_main_item, parent, false) - return SubscriptionViewHolder(view, selected, onClick, onLongClick) + return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -41,7 +42,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon } /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class SubscriptionViewHolder(itemView: View, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) : + class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) : RecyclerView.ViewHolder(itemView) { private var subscription: Subscription? = null private val context: Context = itemView.context @@ -78,11 +79,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon } else { dateStr } + val globalMutedUntil = repository.getGlobalMutedUntil() + val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && subscription.upAppId == null + val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && subscription.upAppId == null nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) statusView.text = statusMessage dateView.text = dateText - notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE - notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE + notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE + notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE if (subscription.upAppId != null || subscription.newCount == 0) { newItemsView.visibility = View.GONE diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index c1a709d..5a60915 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -16,6 +16,7 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Repository import io.heckel.ntfy.util.formatDateShort +import io.heckel.ntfy.util.toPriorityString class SettingsActivity : AppCompatActivity() { private val repository by lazy { (application as Application).repository } @@ -49,7 +50,7 @@ class SettingsActivity : AppCompatActivity() { // everybody has access to the repository. // Notifications muted until (global) - val mutedUntilPrefId = context?.getString(R.string.pref_notifications_muted_until) ?: return + val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return val mutedUntilSummary = { s: Long -> when (s) { 0L -> getString(R.string.settings_notifications_muted_until_enabled) @@ -80,8 +81,53 @@ class SettingsActivity : AppCompatActivity() { true } - // UnifiedPush Enabled - val upEnabledPrefId = context?.getString(R.string.pref_unified_push_enabled) ?: return + // Minimum priority + val minPriorityPrefId = context?.getString(R.string.settings_notifications_min_priority_key) ?: return + val minPriority: ListPreference? = findPreference(minPriorityPrefId) + minPriority?.value = repository.getMinPriority().toString() + minPriority?.preferenceDataStore = object : PreferenceDataStore() { + override fun putString(key: String?, value: String?) { + val minPriorityValue = value?.toIntOrNull() ?:return + repository.setMinPriority(minPriorityValue) + } + override fun getString(key: String?, defValue: String?): String { + return repository.getMinPriority().toString() + } + } + minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref -> + val minPriorityValue = pref.value.toIntOrNull() ?: 1 // 1/low means all priorities + when (minPriorityValue) { + 1 -> getString(R.string.settings_notifications_min_priority_summary_any) + 5 -> getString(R.string.settings_notifications_min_priority_summary_max) + else -> { + val minPriorityString = toPriorityString(minPriorityValue) + getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString) + } + } + } + + // Broadcast enabled + val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return + val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId) + broadcastEnabled?.isChecked = repository.getBroadcastEnabled() + broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() { + override fun putBoolean(key: String?, value: Boolean) { + repository.setBroadcastEnabled(value) + } + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return repository.getBroadcastEnabled() + } + } + broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref -> + if (pref.isChecked) { + getString(R.string.settings_advanced_broadcast_summary_enabled) + } else { + getString(R.string.settings_advanced_broadcast_summary_disabled) + } + } + + // UnifiedPush enabled + val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId) upEnabled?.isChecked = repository.getUnifiedPushEnabled() upEnabled?.preferenceDataStore = object : PreferenceDataStore() { @@ -102,7 +148,7 @@ class SettingsActivity : AppCompatActivity() { // UnifiedPush Base URL val appBaseUrl = context?.getString(R.string.app_base_url) ?: return - val upBaseUrlPrefId = context?.getString(R.string.pref_unified_push_base_url) ?: return + val upBaseUrlPrefId = context?.getString(R.string.settings_unified_push_base_url_key) ?: return val upBaseUrl: EditTextPreference? = findPreference(upBaseUrlPrefId) upBaseUrl?.text = repository.getUnifiedPushBaseUrl() ?: "" upBaseUrl?.preferenceDataStore = object : PreferenceDataStore() { @@ -123,7 +169,7 @@ class SettingsActivity : AppCompatActivity() { } // Version - val versionPrefId = context?.getString(R.string.pref_version) ?: return + val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return val versionPref: Preference? = findPreference(versionPrefId) val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR) versionPref?.summary = version diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 23c3747..50fb66c 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -28,6 +28,17 @@ fun toPriority(priority: Int?): Int { else return 3 } +fun toPriorityString(priority: Int): String { + return when (priority) { + 1 -> "min" + 2 -> "low" + 3 -> "default" + 4 -> "high" + 5 -> "max" + else -> "default" + } +} + fun joinTags(tags: List<String>?): String { return tags?.joinToString(",") ?: "" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d97b73..8260b34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,7 +32,7 @@ <string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string> <string name="main_menu_settings_title">Settings</string> <string name="main_menu_source_title">Report a bug</string> - <string name="main_menu_source_url">https://heckel.io/ntfy-android</string> + <string name="main_menu_source_url">https://github.com/binwiederhier/ntfy/issues</string> <string name="main_menu_website_title">Visit ntfy.sh</string> <!-- Main activity: Action mode --> @@ -147,26 +147,38 @@ <!-- Settings --> <string name="settings_title">Settings</string> <string name="settings_notifications_header">Notifications</string> - <string name="settings_notifications_header_summary">General settings for all subscribed topics</string> + <string name="settings_notifications_muted_until_key">MutedUntil</string> <string name="settings_notifications_muted_until_title">Pause notifications</string> <string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string> <string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string> <string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string> + <string name="settings_notifications_min_priority_key">MinPriority</string> + <string name="settings_notifications_min_priority_title">Minimum priority</string> + <string name="settings_notifications_min_priority_summary_any">Notifications of all priorities are shown</string> + <string name="settings_notifications_min_priority_summary_x_or_higher">Show notifications if priority is %1$d (%2$s) or higher</string> + <string name="settings_notifications_min_priority_summary_max">Show notifications if priority is 5 (max)</string> + <string name="settings_notifications_min_priority_min">Any priority</string> + <string name="settings_notifications_min_priority_low">Low priority and higher</string> + <string name="settings_notifications_min_priority_default">Default priority and higher</string> + <string name="settings_notifications_min_priority_high">High priority and higher</string> + <string name="settings_notifications_min_priority_max">Only max priority</string> <string name="settings_unified_push_header">UnifiedPush</string> <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string> + <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string> <string name="settings_unified_push_enabled_title">Enable distributor</string> <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string> <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string> + <string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string> <string name="settings_unified_push_base_url_title">Server URL</string> <string name="settings_unified_push_base_url_default_summary">%1$s (default)</string> + <string name="settings_advanced_header">Advanced</string> + <string name="settings_advanced_broadcast_key">BroadcastEnabled</string> + <string name="settings_advanced_broadcast_title">Broadcast messages</string> + <string name="settings_advanced_broadcast_summary_enabled">Apps can receive incoming notifications as broadcasts</string> + <string name="settings_advanced_broadcast_summary_disabled">Apps cannot receive notifications as broadcasts</string> <string name="settings_about_header">About</string> + <string name="settings_about_version_key">Version</string> <string name="settings_about_version_title">Version</string> <string name="settings_about_version_format">ntfy %1$s (%2$s)</string> <string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string> - - <!-- Preferences IDs --> - <string name="pref_notifications_muted_until">MutedUntil</string> - <string name="pref_unified_push_enabled">UnifiedPushEnabled</string> - <string name="pref_unified_push_base_url">UnifiedPushBaseURL</string> - <string name="pref_version">Version</string> </resources> diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml new file mode 100644 index 0000000..4dfb50c --- /dev/null +++ b/app/src/main/res/values/values.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string-array name="settings_notifications_min_priority_entries"> + <item>@string/settings_notifications_min_priority_min</item> + <item>@string/settings_notifications_min_priority_low</item> + <item>@string/settings_notifications_min_priority_default</item> + <item>@string/settings_notifications_min_priority_high</item> + <item>@string/settings_notifications_min_priority_max</item> + </string-array> + <string-array name="settings_notifications_min_priority_values"> + <item>1</item> + <item>2</item> + <item>3</item> + <item>4</item> + <item>5</item> + </string-array> +</resources> diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index c404af4..5295d26 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -3,28 +3,39 @@ app:title="@string/settings_title"> <PreferenceCategory app:title="@string/settings_notifications_header" - app:summary="@string/settings_notifications_header_summary" app:layout="@layout/preference_category_material_edited"> <Preference - app:key="@string/pref_notifications_muted_until" + app:key="@string/settings_notifications_muted_until_key" app:title="@string/settings_notifications_muted_until_title"/> + <ListPreference + app:key="@string/settings_notifications_min_priority_key" + app:title="@string/settings_notifications_min_priority_title" + app:entries="@array/settings_notifications_min_priority_entries" + app:entryValues="@array/settings_notifications_min_priority_values" + app:defaultValue="1"/> </PreferenceCategory> <PreferenceCategory app:title="@string/settings_unified_push_header" app:summary="@string/settings_unified_push_header_summary" app:layout="@layout/preference_category_material_edited"> <SwitchPreference - app:key="@string/pref_unified_push_enabled" + app:key="@string/settings_unified_push_enabled_key" app:title="@string/settings_unified_push_enabled_title" app:enabled="true"/> <EditTextPreference - app:key="@string/pref_unified_push_base_url" + app:key="@string/settings_unified_push_base_url_key" app:title="@string/settings_unified_push_base_url_title" - app:dependency="@string/pref_unified_push_enabled"/> + app:dependency="@string/settings_unified_push_enabled_key"/> + </PreferenceCategory> + <PreferenceCategory app:title="@string/settings_advanced_header"> + <SwitchPreference + app:key="@string/settings_advanced_broadcast_key" + app:title="@string/settings_advanced_broadcast_title" + app:enabled="true"/> </PreferenceCategory> <PreferenceCategory app:title="@string/settings_about_header"> <Preference - app:key="@string/pref_version" + app:key="@string/settings_about_version_key" app:title="@string/settings_about_version_title"/> </PreferenceCategory> </PreferenceScreen> From bf419dda23b6ebac06372819d03aeb254d24fe01 Mon Sep 17 00:00:00 2001 From: Philipp Heckel <pheckel@datto.com> Date: Sat, 1 Jan 2022 17:09:00 +0100 Subject: [PATCH 16/16] Always show UP subscriptions at the bottom --- app/src/main/java/io/heckel/ntfy/data/Database.kt | 4 ++-- app/src/main/java/io/heckel/ntfy/data/Repository.kt | 1 - app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt | 10 ++++++---- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 8fc2325..578663f 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -129,7 +129,7 @@ interface SubscriptionDao { FROM Subscription AS s LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 GROUP BY s.id - ORDER BY MAX(n.timestamp) DESC + ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC """) fun listFlow(): Flow<List<SubscriptionWithMetadata>> @@ -142,7 +142,7 @@ interface SubscriptionDao { FROM Subscription AS s LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 GROUP BY s.id - ORDER BY MAX(n.timestamp) DESC + ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC """) fun list(): List<SubscriptionWithMetadata> diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 0907ca4..bf4971d 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -4,7 +4,6 @@ import android.content.SharedPreferences import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.* -import androidx.preference.PreferenceManager import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index 6a361ed..72c926e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -56,7 +56,8 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs fun bind(subscription: Subscription) { this.subscription = subscription - var statusMessage = if (subscription.upAppId != null) { + val isUnifiedPush = subscription.upAppId != null + var statusMessage = if (isUnifiedPush) { context.getString(R.string.main_item_status_unified_push, subscription.upAppId) } else if (subscription.totalCount == 1) { context.getString(R.string.main_item_status_text_one, subscription.totalCount) @@ -80,15 +81,16 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs dateStr } val globalMutedUntil = repository.getGlobalMutedUntil() - val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && subscription.upAppId == null - val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && subscription.upAppId == null + val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush + val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) statusView.text = statusMessage dateView.text = dateText + dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE - if (subscription.upAppId != null || subscription.newCount == 0) { + if (isUnifiedPush || subscription.newCount == 0) { newItemsView.visibility = View.GONE } else { newItemsView.visibility = View.VISIBLE diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8260b34..1767b78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,7 +165,7 @@ <string name="settings_unified_push_header">UnifiedPush</string> <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string> <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string> - <string name="settings_unified_push_enabled_title">Enable distributor</string> + <string name="settings_unified_push_enabled_title">Allow distributor use</string> <string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string> <string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string> <string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string>