Dismiss notifications when detail view is opened, show new bubble

This commit is contained in:
Philipp Heckel 2021-11-15 16:24:31 -05:00
parent a44e551809
commit 0ab3bdc2a0
13 changed files with 213 additions and 61 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 2, "version": 2,
"identityHash": "df0a0eab3fc3056bf12e04a09c084660", "identityHash": "4b24fe9241d824ae94f32a31e41841c8",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -54,7 +54,7 @@
}, },
{ {
"tableName": "Notification", "tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -80,6 +80,12 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "deleted", "fieldPath": "deleted",
"columnName": "deleted", "columnName": "deleted",
@ -100,7 +106,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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, 'df0a0eab3fc3056bf12e04a09c084660')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4b24fe9241d824ae94f32a31e41841c8')"
] ]
} }
} }

View file

@ -12,11 +12,12 @@ data class Subscription(
@ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "instant") val instant: Boolean,
@Ignore val notifications: Int, @Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) { ) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, ConnectionState.NOT_APPLICABLE) constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
} }
enum class ConnectionState { enum class ConnectionState {
@ -28,7 +29,8 @@ data class SubscriptionWithMetadata(
val baseUrl: String, val baseUrl: String,
val topic: String, val topic: String,
val instant: Boolean, val instant: Boolean,
val notifications: Int, val totalCount: Int,
val newCount: Int,
val lastActive: Long val lastActive: Long
) )
@ -38,7 +40,8 @@ data class Notification(
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long, @ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "message") val message: String, @ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "deleted") val deleted: Boolean @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "deleted") val deleted: Boolean,
) )
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2) @androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2)
@ -71,7 +74,8 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription") db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)") db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)")
// Add "deleted" column // Add "notificationId" & "deleted" columns
db.execSQL("ALTER TABLE Notification ADD COLUMN notificationId INTEGER NOT NULL DEFAULT('0')")
db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')") db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
} }
} }
@ -80,40 +84,56 @@ abstract class Database : RoomDatabase() {
@Dao @Dao
interface SubscriptionDao { interface SubscriptionDao {
@Query( @Query("""
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + SELECT
"FROM subscription AS s " + s.id, s.baseUrl, s.topic, s.instant,
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + COUNT(n.id) totalCount,
"GROUP BY s.id " + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
"ORDER BY MAX(n.timestamp) DESC" IFNULL(MAX(n.timestamp),0) AS lastActive
) FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
GROUP BY s.id
ORDER BY MAX(n.timestamp) DESC
""")
fun listFlow(): Flow<List<SubscriptionWithMetadata>> fun listFlow(): Flow<List<SubscriptionWithMetadata>>
@Query( @Query("""
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + SELECT
"FROM subscription AS s " + s.id, s.baseUrl, s.topic, s.instant,
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + COUNT(n.id) totalCount,
"GROUP BY s.id " + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
"ORDER BY MAX(n.timestamp) DESC" IFNULL(MAX(n.timestamp),0) AS lastActive
) FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
GROUP BY s.id
ORDER BY MAX(n.timestamp) DESC
""")
fun list(): List<SubscriptionWithMetadata> fun list(): List<SubscriptionWithMetadata>
@Query( @Query("""
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + SELECT
"FROM subscription AS s " + s.id, s.baseUrl, s.topic, s.instant,
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + COUNT(n.id) totalCount,
"WHERE s.baseUrl = :baseUrl AND s.topic = :topic " + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
"GROUP BY s.id " 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.baseUrl = :baseUrl AND s.topic = :topic
GROUP BY s.id
""")
fun get(baseUrl: String, topic: String): SubscriptionWithMetadata? fun get(baseUrl: String, topic: String): SubscriptionWithMetadata?
@Query( @Query("""
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + SELECT
"FROM subscription AS s " + s.id, s.baseUrl, s.topic, s.instant,
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + COUNT(n.id) totalCount,
"WHERE s.id = :subscriptionId " + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
"GROUP BY s.id " 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
GROUP BY s.id
""")
fun get(subscriptionId: Long): SubscriptionWithMetadata? fun get(subscriptionId: Long): SubscriptionWithMetadata?
@Insert @Insert
@ -140,6 +160,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE id = :notificationId") @Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification? fun get(notificationId: String): Notification?
@Update
fun update(notification: Notification)
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
fun remove(notificationId: String) fun remove(notificationId: String)

View file

@ -4,10 +4,12 @@ import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.* import androidx.lifecycle.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>() private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates) private val connectionStatesLiveData = MutableLiveData(connectionStates)
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
init { init {
Log.d(TAG, "Created $this") Log.d(TAG, "Created $this")
@ -87,6 +89,10 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
return false return false
} }
fun updateNotification(notification: Notification) {
notificationDao.update(notification)
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun removeNotification(notificationId: String) { suspend fun removeNotification(notificationId: String) {
@ -107,8 +113,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications,
state = connectionState state = connectionState
) )
} }
@ -123,8 +130,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications,
state = getState(s.id) state = getState(s.id)
) )
} }

View file

@ -10,6 +10,7 @@ import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random
class ApiService { class ApiService {
private val gson = Gson() private val gson = Gson()
@ -74,7 +75,14 @@ class ApiService {
val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null") val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null")
val message = gson.fromJson(line, Message::class.java) val message = gson.fromJson(line, Message::class.java)
if (message.event == EVENT_MESSAGE) { if (message.event == EVENT_MESSAGE) {
val notification = Notification(message.id, subscriptionId, message.time, message.message, false) val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
timestamp = message.time,
message = message.message,
notificationId = Random.nextInt(),
deleted = false
)
notify(notification) notify(notification)
} }
} }
@ -93,7 +101,7 @@ class ApiService {
private fun fromString(subscriptionId: Long, s: String): Notification { private fun fromString(subscriptionId: Long, s: String): Notification {
val n = gson.fromJson(s, Message::class.java) val n = gson.fromJson(s, Message::class.java)
return Notification(n.id, subscriptionId, n.time, n.message, false) return Notification(n.id, subscriptionId, n.time, n.message, notificationId = 0, deleted = false)
} }
private data class Message( private data class Message(

View file

@ -9,6 +9,7 @@ import io.heckel.ntfy.data.Notification
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() { class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
@ -39,13 +40,21 @@ class FirebaseService : FirebaseMessagingService() {
// Add notification // Add notification
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false) val notification = Notification(
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
message = message,
notificationId = Random.nextInt(),
deleted = false
)
val added = repository.addNotification(notification) val added = repository.addNotification(notification)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
// Send notification (only if it's not already known) // Send notification (only if it's not already known)
if (added) { if (added && !detailViewOpen) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, message) notifier.send(subscription, notification)
} }
} }
} }

View file

@ -11,6 +11,7 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.DetailActivity
@ -18,15 +19,16 @@ import io.heckel.ntfy.ui.MainActivity
import kotlin.random.Random import kotlin.random.Random
class NotificationService(val context: Context) { class NotificationService(val context: Context) {
fun send(subscription: Subscription, message: String) { fun send(subscription: Subscription, notification: Notification) {
val title = topicShortUrl(subscription.baseUrl, subscription.topic) val title = topicShortUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "Displaying notification $title: $message") Log.d(TAG, "Displaying notification $title: ${notification.message}")
// Create an Intent for the activity you want to start // Create an Intent for the activity you want to start
val intent = Intent(context, DetailActivity::class.java) val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run { val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack
@ -36,7 +38,7 @@ class NotificationService(val context: Context) {
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_icon) .setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(notification.message)
.setSound(defaultSoundUri) .setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // Click target for notification .setContentIntent(pendingIntent) // Click target for notification
.setAutoCancel(true) // Cancel when notification is clicked .setAutoCancel(true) // Cancel when notification is clicked
@ -47,7 +49,15 @@ class NotificationService(val context: Context) {
val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT) val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
notificationManager.notify(Random.nextInt(), notificationBuilder.build()) notificationManager.notify(notification.notificationId, notificationBuilder.build())
}
fun cancel(notification: Notification) {
if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notification.notificationId)
}
} }
companion object { companion object {

View file

@ -205,9 +205,11 @@ class SubscriberService : Service() {
Log.d(TAG, "[$url] Received notification: $n") Log.d(TAG, "[$url] Received notification: $n")
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val added = repository.addNotification(n) val added = repository.addNotification(n)
if (added) { val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
if (added && !detailViewOpen) {
Log.d(TAG, "[$url] Showing notification: $n") Log.d(TAG, "[$url] Showing notification: $n")
notifier.send(subscription, n.message) notifier.send(subscription, n)
} }
} }
} }

View file

@ -26,11 +26,12 @@ import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicLong
// TODO dismiss notifications when navigating to detail page
class DetailActivity : AppCompatActivity(), ActionMode.Callback { class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val viewModel by viewModels<DetailViewModel> { private val viewModel by viewModels<DetailViewModel> {
@ -39,6 +40,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
private var subscriberManager: SubscriberManager? = null // Context-dependent private var subscriberManager: SubscriberManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
// Which subscription are we looking at // Which subscription are we looking at
private var subscriptionId: Long = 0L // Set in onCreate() private var subscriptionId: Long = 0L // Set in onCreate()
@ -63,6 +65,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
// Dependencies that depend on Context // Dependencies that depend on Context
subscriberManager = SubscriberManager(this) subscriberManager = SubscriberManager(this)
notifier = NotificationService(this)
// Show 'Back' button // Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
@ -105,6 +108,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
viewModel.list(subscriptionId).observe(this) { viewModel.list(subscriptionId).observe(this) {
it?.let { it?.let {
// Show list view
adapter.submitList(it as MutableList<Notification>) adapter.submitList(it as MutableList<Notification>)
if (it.isEmpty()) { if (it.isEmpty()) {
mainListContainer.visibility = View.GONE mainListContainer.visibility = View.GONE
@ -113,13 +117,61 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
mainListContainer.visibility = View.VISIBLE mainListContainer.visibility = View.VISIBLE
noEntriesText.visibility = View.GONE noEntriesText.visibility = View.GONE
} }
// Cancel notifications that still have popups
maybeCancelNotificationPopups(it)
} }
} }
// Scroll up when new notification is added
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
Log.d(TAG, "$itemCount item(s) inserted at $positionStart, scrolling to the top")
mainList.scrollToPosition(positionStart)
}
}
})
// React to changes in fast delivery setting // React to changes in fast delivery setting
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
subscriberManager?.refreshService(it) subscriberManager?.refreshService(it)
} }
// Mark this subscription as "open" so we don't receive notifications for it
Log.d(TAG, "onCreate hook: Marking subscription $subscriptionId as 'open'")
repository.detailViewSubscriptionId.set(subscriptionId)
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'open'")
repository.detailViewSubscriptionId.set(subscriptionId) // Mark as "open" so we don't send notifications while this is open
}
override fun onPause() {
super.onPause()
Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'not open'")
repository.detailViewSubscriptionId.set(0) // Mark as closed
}
override fun onDestroy() {
repository.detailViewSubscriptionId.set(0) // Mark as closed
Log.d(TAG, "onDestroy hook: Marking subscription $subscriptionId as 'not open'")
super.onDestroy()
}
private fun maybeCancelNotificationPopups(notifications: List<Notification>) {
val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 }
if (notificationsWithPopups.isNotEmpty()) {
lifecycleScope.launch(Dispatchers.IO) {
notificationsWithPopups.forEach { notification ->
notifier?.cancel(notification)
repository.updateNotification(notification.copy(notificationId = 0))
}
}
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -421,5 +473,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
companion object { companion object {
const val TAG = "NtfyDetailActivity" const val TAG = "NtfyDetailActivity"
const val CANCEL_NOTIFICATION_DELAY_MILLIS = 20_000L
} }
} }

View file

@ -165,7 +165,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
baseUrl = baseUrl, baseUrl = baseUrl,
topic = topic, topic = topic,
instant = instant, instant = instant,
notifications = 0, totalCount = 0,
newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
) )
viewModel.add(subscription) viewModel.add(subscription)
@ -221,8 +222,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
repository.addNotification(notification) val notificationWithId = notification.copy(notificationId = Random.nextInt())
notifier?.send(subscription, notification.message) repository.addNotification(notificationWithId)
notifier?.send(subscription, notificationWithId)
newNotificationsCount++ newNotificationsCount++
} }
} }
@ -262,7 +264,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0) val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0)
val subscriptionBaseUrl = data?.getStringExtra(EXTRA_SUBSCRIPTION_BASE_URL) val subscriptionBaseUrl = data?.getStringExtra(EXTRA_SUBSCRIPTION_BASE_URL)
val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC) val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC)
val subscriptionInstant = data?.getBooleanExtra(EXTRA_SUBSCRIPTION_INSTANT, false)
Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)") Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)")
subscriptionId?.let { id -> viewModel.remove(id) } subscriptionId?.let { id -> viewModel.remove(id) }

View file

@ -50,13 +50,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val statusView: TextView = itemView.findViewById(R.id.main_item_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date) private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image) private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
this.subscription = subscription this.subscription = subscription
var statusMessage = if (subscription.notifications == 1) { var statusMessage = if (subscription.totalCount == 1) {
context.getString(R.string.main_item_status_text_one, subscription.notifications) context.getString(R.string.main_item_status_text_one, subscription.totalCount)
} else { } else {
context.getString(R.string.main_item_status_text_not_one, subscription.notifications) context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
} }
if (subscription.instant && subscription.state == ConnectionState.RECONNECTING) { if (subscription.instant && subscription.state == ConnectionState.RECONNECTING) {
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting) statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
@ -76,6 +77,12 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} else { } else {
instantImageView.visibility = View.GONE instantImageView.visibility = View.GONE
} }
if (subscription.newCount > 0) {
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.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true } itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) { if (selected.contains(subscription.id)) {

View file

@ -11,6 +11,7 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.random.Random
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
// Every time the worker is changed, the periodic work has to be REPLACEd. // Every time the worker is changed, the periodic work has to be REPLACEd.
@ -27,10 +28,16 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
try { try {
repository.getSubscriptions().forEach{ subscription -> repository.getSubscriptions().forEach{ subscription ->
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
repository.addNotification(notification) val added = repository.addNotification(notification)
notifier.send(subscription, notification.message) val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
if (added && !detailViewOpen) {
notifier.send(subscription, notification)
}
} }
} }
Log.d(TAG, "Finished polling for new notifications") Log.d(TAG, "Finished polling for new notifications")

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval" >
<solid android:color="#338574" />
</shape>

View file

@ -43,5 +43,18 @@
app:layout_constraintTop_toTopOf="@+id/main_item_instant_image" app:layout_constraintTop_toTopOf="@+id/main_item_instant_image"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="15dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="15dp"
android:paddingTop="2dp"/> android:paddingTop="2dp"/>
<TextView
android:text="99+"
android:layout_width="24dp"
android:layout_height="24dp" android:id="@+id/main_item_new"
android:layout_marginTop="3dp"
android:layout_gravity="center"
android:background="@drawable/ic_circle"
android:gravity="center"
android:textColor="@android:color/white"
app:layout_constraintTop_toBottomOf="@+id/main_item_date"
app:layout_constraintEnd_toEndOf="@+id/main_item_date"
app:layout_constraintStart_toEndOf="@+id/main_item_instant_image"
android:textSize="10sp" android:textStyle="bold"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>