Dismiss notifications when detail view is opened, show new bubble
This commit is contained in:
parent
a44e551809
commit
0ab3bdc2a0
13 changed files with 213 additions and 61 deletions
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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")
|
||||||
|
|
5
app/src/main/res/drawable/ic_circle.xml
Normal file
5
app/src/main/res/drawable/ic_circle.xml
Normal 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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue