Remove denormalized subscription, add schema migration for sqlite db
This commit is contained in:
parent
72d7a2f93d
commit
86738d5441
13 changed files with 265 additions and 61 deletions
|
@ -16,6 +16,14 @@ android {
|
||||||
versionName "1.0.1"
|
versionName "1.0.1"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
/* Required for Room schema migrations */
|
||||||
|
javaCompileOptions {
|
||||||
|
annotationProcessorOptions {
|
||||||
|
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
100
app/schemas/io.heckel.ntfy.data.Database/2.json
Normal file
100
app/schemas/io.heckel.ntfy.data.Database/2.json
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "30177aa8688290d24499babf22b15720",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "Subscription",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` 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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, `message` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"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": "message",
|
||||||
|
"columnName": "message",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "deleted",
|
||||||
|
"columnName": "deleted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"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, '30177aa8688290d24499babf22b15720')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,11 @@
|
||||||
package io.heckel.ntfy.data
|
package io.heckel.ntfy.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.NonNull
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
|
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
|
||||||
|
@ -10,8 +13,18 @@ data class Subscription(
|
||||||
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
|
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
|
||||||
@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 = "notifications") val notifications: Int,
|
@Ignore val notifications: Int,
|
||||||
@ColumnInfo(name = "lastActive") val lastActive: Long, // Unix timestamp
|
@Ignore val lastActive: Long = 0 // Unix timestamp
|
||||||
|
) {
|
||||||
|
constructor(id: Long, baseUrl: String, topic: String) : this(id, baseUrl, topic, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SubscriptionWithMetadata(
|
||||||
|
val id: Long,
|
||||||
|
val baseUrl: String,
|
||||||
|
val topic: String,
|
||||||
|
val notifications: Int,
|
||||||
|
val lastActive: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
@ -19,10 +32,11 @@ data class Notification(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
@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
|
||||||
)
|
)
|
||||||
|
|
||||||
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 1)
|
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2)
|
||||||
abstract class Database : RoomDatabase() {
|
abstract class Database : RoomDatabase() {
|
||||||
abstract fun subscriptionDao(): SubscriptionDao
|
abstract fun subscriptionDao(): SubscriptionDao
|
||||||
abstract fun notificationDao(): NotificationDao
|
abstract fun notificationDao(): NotificationDao
|
||||||
|
@ -35,45 +49,81 @@ abstract class Database : RoomDatabase() {
|
||||||
return instance ?: synchronized(this) {
|
return instance ?: synchronized(this) {
|
||||||
val instance = Room
|
val instance = Room
|
||||||
.databaseBuilder(context.applicationContext, Database::class.java,"AppDatabase")
|
.databaseBuilder(context.applicationContext, Database::class.java,"AppDatabase")
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
this.instance = instance
|
this.instance = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// Drop "notifications" & "lastActive" columns (SQLite does not support dropping columns, ...)
|
||||||
|
db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, PRIMARY KEY(id))")
|
||||||
|
db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic FROM Subscription")
|
||||||
|
db.execSQL("DROP TABLE Subscription")
|
||||||
|
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)")
|
||||||
|
|
||||||
|
// Add "deleted" column
|
||||||
|
db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface SubscriptionDao {
|
interface SubscriptionDao {
|
||||||
@Query("SELECT * FROM subscription ORDER BY lastActive DESC")
|
@Query(
|
||||||
fun listFlow(): Flow<List<Subscription>>
|
"SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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>>
|
||||||
|
|
||||||
@Query("SELECT * FROM subscription ORDER BY lastActive DESC")
|
@Query(
|
||||||
fun list(): List<Subscription>
|
"SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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>
|
||||||
|
|
||||||
@Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic")
|
@Query(
|
||||||
fun get(baseUrl: String, topic: String): Subscription?
|
"SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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?
|
||||||
|
|
||||||
@Query("SELECT * FROM subscription WHERE id = :subscriptionId")
|
@Query(
|
||||||
fun get(subscriptionId: Long): Subscription?
|
"SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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?
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
fun add(subscription: Subscription)
|
fun add(subscription: Subscription)
|
||||||
|
|
||||||
@Update
|
|
||||||
fun update(subscription: Subscription)
|
|
||||||
|
|
||||||
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
||||||
fun remove(subscriptionId: Long)
|
fun remove(subscriptionId: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface NotificationDao {
|
interface NotificationDao {
|
||||||
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC")
|
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
|
||||||
fun list(subscriptionId: Long): Flow<List<Notification>>
|
fun list(subscriptionId: Long): Flow<List<Notification>>
|
||||||
|
|
||||||
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId")
|
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
|
||||||
fun listIds(subscriptionId: Long): List<String>
|
fun listIds(subscriptionId: Long): List<String>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
@ -82,7 +132,7 @@ 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?
|
||||||
|
|
||||||
@Query("DELETE FROM notification WHERE id = :notificationId")
|
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
|
||||||
fun remove(notificationId: String)
|
fun remove(notificationId: String)
|
||||||
|
|
||||||
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")
|
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")
|
||||||
|
|
|
@ -4,7 +4,8 @@ import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.asLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
import java.util.*
|
import androidx.lifecycle.map
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
|
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
|
||||||
init {
|
init {
|
||||||
|
@ -12,17 +13,20 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptionsLiveData(): LiveData<List<Subscription>> {
|
fun getSubscriptionsLiveData(): LiveData<List<Subscription>> {
|
||||||
return subscriptionDao.listFlow().asLiveData()
|
return subscriptionDao
|
||||||
|
.listFlow()
|
||||||
|
.asLiveData()
|
||||||
|
.map { list -> toSubscriptionList(list) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptions(): List<Subscription> {
|
fun getSubscriptions(): List<Subscription> {
|
||||||
return subscriptionDao.list()
|
return toSubscriptionList(subscriptionDao.list())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
|
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
|
||||||
return subscriptionDao.get(baseUrl, topic)
|
return toSubscription(subscriptionDao.get(baseUrl, topic))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@ -31,12 +35,6 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
subscriptionDao.add(subscription)
|
subscriptionDao.add(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun updateSubscription(subscription: Subscription) {
|
|
||||||
subscriptionDao.update(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun removeSubscription(subscriptionId: Long) {
|
suspend fun removeSubscription(subscriptionId: Long) {
|
||||||
|
@ -54,24 +52,16 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun addNotification(subscriptionId: Long, notification: Notification) {
|
suspend fun addNotification(notification: Notification) {
|
||||||
val maybeExistingNotification = notificationDao.get(notification.id)
|
val maybeExistingNotification = notificationDao.get(notification.id)
|
||||||
if (maybeExistingNotification != null) {
|
if (maybeExistingNotification == null) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val subscription = subscriptionDao.get(subscriptionId) ?: return
|
|
||||||
val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000)
|
|
||||||
subscriptionDao.update(newSubscription)
|
|
||||||
notificationDao.add(notification)
|
notificationDao.add(notification)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun removeNotification(subscriptionId: Long, notificationId: String) {
|
suspend fun removeNotification(notificationId: String) {
|
||||||
val subscription = subscriptionDao.get(subscriptionId) ?: return
|
|
||||||
val newSubscription = subscription.copy(notifications = subscription.notifications - 1, lastActive = Date().time/1000)
|
|
||||||
subscriptionDao.update(newSubscription)
|
|
||||||
notificationDao.remove(notificationId)
|
notificationDao.remove(notificationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +71,31 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
notificationDao.removeAll(subscriptionId)
|
notificationDao.removeAll(subscriptionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
|
||||||
|
return list.map { s ->
|
||||||
|
Subscription(
|
||||||
|
id = s.id,
|
||||||
|
baseUrl = s.baseUrl,
|
||||||
|
topic = s.topic,
|
||||||
|
lastActive = s.lastActive,
|
||||||
|
notifications = s.notifications
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSubscription(s: SubscriptionWithMetadata?): Subscription? {
|
||||||
|
if (s == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Subscription(
|
||||||
|
id = s.id,
|
||||||
|
baseUrl = s.baseUrl,
|
||||||
|
topic = s.topic,
|
||||||
|
lastActive = s.lastActive,
|
||||||
|
notifications = s.notifications
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "NtfyRepository"
|
private val TAG = "NtfyRepository"
|
||||||
private var instance: Repository? = null
|
private var instance: Repository? = null
|
||||||
|
|
|
@ -53,7 +53,7 @@ class ApiService {
|
||||||
|
|
||||||
private fun fromString(subscriptionId: Long, s: String): Notification {
|
private fun fromString(subscriptionId: Long, s: String): Notification {
|
||||||
val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc.
|
val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc.
|
||||||
return Notification(n.id, subscriptionId, n.time, n.message)
|
return Notification(n.id, subscriptionId, n.time, n.message, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class NotificationData(
|
private data class NotificationData(
|
||||||
|
|
|
@ -39,8 +39,8 @@ 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)
|
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false)
|
||||||
repository.addNotification(subscription.id, notification)
|
repository.addNotification(notification)
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
||||||
|
|
|
@ -23,6 +23,7 @@ import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.app.Application
|
import io.heckel.ntfy.app.Application
|
||||||
import io.heckel.ntfy.data.Notification
|
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.msg.ApiService
|
import io.heckel.ntfy.msg.ApiService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -113,6 +114,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
onRefreshClick()
|
onRefreshClick()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.detail_menu_copy_url -> {
|
||||||
|
onCopyUrlClick()
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.detail_menu_unsubscribe -> {
|
R.id.detail_menu_unsubscribe -> {
|
||||||
onDeleteClick()
|
onDeleteClick()
|
||||||
true
|
true
|
||||||
|
@ -136,6 +141,18 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onCopyUrlClick() {
|
||||||
|
val url = topicUrl(subscriptionBaseUrl, subscriptionTopic)
|
||||||
|
Log.d(TAG, "Copying topic URL $url to clipboard ")
|
||||||
|
|
||||||
|
val clipboard: ClipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = ClipData.newPlainText("topic address", url)
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
Toast
|
||||||
|
.makeText(this, getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun onRefreshClick() {
|
private fun onRefreshClick() {
|
||||||
Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||||
|
|
||||||
|
@ -144,15 +161,15 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic)
|
val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic)
|
||||||
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
|
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
|
||||||
val toastMessage = if (newNotifications.isEmpty()) {
|
val toastMessage = if (newNotifications.isEmpty()) {
|
||||||
getString(R.string.detail_refresh_message_no_results)
|
getString(R.string.refresh_message_no_results)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.detail_refresh_message_result, newNotifications.size)
|
getString(R.string.refresh_message_result, newNotifications.size)
|
||||||
}
|
}
|
||||||
newNotifications.forEach { notification -> repository.addNotification(subscriptionId, notification) }
|
newNotifications.forEach { notification -> repository.addNotification(notification) }
|
||||||
runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
|
runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Toast
|
Toast
|
||||||
.makeText(this@DetailActivity, getString(R.string.detail_refresh_message_error, e.message), Toast.LENGTH_LONG)
|
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -243,7 +260,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
builder
|
builder
|
||||||
.setMessage(R.string.detail_action_mode_delete_dialog_message)
|
.setMessage(R.string.detail_action_mode_delete_dialog_message)
|
||||||
.setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
.setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
||||||
adapter.selected.map { notificationId -> viewModel.remove(subscriptionId, notificationId) }
|
adapter.selected.map { notificationId -> viewModel.remove(notificationId) }
|
||||||
finishActionMode()
|
finishActionMode()
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->
|
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->
|
||||||
|
|
|
@ -14,8 +14,8 @@ class DetailViewModel(private val repository: Repository) : ViewModel() {
|
||||||
return repository.getNotificationsLiveData(subscriptionId)
|
return repository.getNotificationsLiveData(subscriptionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(subscriptionId: Long, notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
|
fun remove(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
repository.removeNotification(subscriptionId, notificationId)
|
repository.removeNotification(notificationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -76,8 +76,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
mainList.adapter = adapter
|
mainList.adapter = adapter
|
||||||
|
|
||||||
viewModel.list().observe(this) {
|
viewModel.list().observe(this) {
|
||||||
it?.let {
|
it?.let { subscriptions ->
|
||||||
adapter.submitList(it as MutableList<Subscription>)
|
adapter.submitList(subscriptions as MutableList<Subscription>)
|
||||||
if (it.isEmpty()) {
|
if (it.isEmpty()) {
|
||||||
mainList.visibility = View.GONE
|
mainList.visibility = View.GONE
|
||||||
noEntries.visibility = View.VISIBLE
|
noEntries.visibility = View.VISIBLE
|
||||||
|
@ -168,7 +168,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
|
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
|
||||||
notifications.forEach { notification -> repository.addNotification(subscription.id, notification) }
|
notifications.forEach { notification -> repository.addNotification(notification) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}")
|
Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}")
|
||||||
}
|
}
|
||||||
|
@ -196,19 +196,27 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Polling for new notifications")
|
Log.d(TAG, "Polling for new notifications")
|
||||||
|
var newNotificationsCount = 0
|
||||||
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)
|
||||||
newNotifications.forEach { notification ->
|
newNotifications.forEach { notification ->
|
||||||
repository.addNotification(subscription.id, notification)
|
repository.addNotification(notification)
|
||||||
notifier?.send(subscription, notification.message)
|
notifier?.send(subscription, notification.message)
|
||||||
|
newNotificationsCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val toastMessage = if (newNotificationsCount == 0) {
|
||||||
|
getString(R.string.refresh_message_no_results)
|
||||||
|
} else {
|
||||||
|
getString(R.string.refresh_message_result, newNotificationsCount)
|
||||||
|
}
|
||||||
|
runOnUiThread { Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show() }
|
||||||
Log.d(TAG, "Finished polling for new notifications")
|
Log.d(TAG, "Finished polling for new notifications")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Polling failed: ${e.message}", e)
|
Log.e(TAG, "Polling failed: ${e.message}", e)
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
Toast.makeText(this@MainActivity, getString(R.string.poll_worker_exception, e.message), Toast.LENGTH_LONG).show()
|
Toast.makeText(this@MainActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,10 +56,12 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
||||||
} 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.notifications)
|
||||||
}
|
}
|
||||||
val dateText = if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) {
|
val dateText = if (subscription.lastActive == 0L) {
|
||||||
|
""
|
||||||
|
} else if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) {
|
||||||
SimpleDateFormat("HH:mm").format(Date(subscription.lastActive*1000))
|
SimpleDateFormat("HH:mm").format(Date(subscription.lastActive*1000))
|
||||||
} else {
|
} else {
|
||||||
SimpleDateFormat("MM/dd").format(Date(subscription.lastActive*1000))
|
SimpleDateFormat("M/d/yy").format(Date(subscription.lastActive*1000))
|
||||||
}
|
}
|
||||||
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||||
statusView.text = statusMessage
|
statusView.text = statusMessage
|
||||||
|
|
|
@ -31,7 +31,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
||||||
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(subscription.id, notification)
|
repository.addNotification(notification)
|
||||||
notifier.send(subscription, notification.message)
|
notifier.send(subscription, notification.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
|
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
|
||||||
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
|
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
|
||||||
|
<item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/>
|
||||||
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
|
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -7,6 +7,11 @@
|
||||||
<string name="notification_channel_name">Ntfy</string>
|
<string name="notification_channel_name">Ntfy</string>
|
||||||
<string name="notification_channel_id">ntfy</string>
|
<string name="notification_channel_id">ntfy</string>
|
||||||
|
|
||||||
|
<!-- Common refresh toasts -->
|
||||||
|
<string name="refresh_message_result">%1$d notification(s) received</string>
|
||||||
|
<string name="refresh_message_no_results">Everything is up-to-date</string>
|
||||||
|
<string name="refresh_message_error">Could not refresh topic: %1$s</string>
|
||||||
|
|
||||||
<!-- Main activity: Action bar -->
|
<!-- Main activity: Action bar -->
|
||||||
<string name="main_action_bar_title">Subscribed topics</string>
|
<string name="main_action_bar_title">Subscribed topics</string>
|
||||||
<string name="main_menu_refresh">Force refresh</string>
|
<string name="main_menu_refresh">Force refresh</string>
|
||||||
|
@ -46,13 +51,11 @@
|
||||||
<string name="detail_delete_dialog_cancel">Cancel</string>
|
<string name="detail_delete_dialog_cancel">Cancel</string>
|
||||||
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
|
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
|
||||||
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
||||||
<string name="detail_refresh_message_result">%1$d notification(s) added</string>
|
|
||||||
<string name="detail_refresh_message_no_results">No new notifications found</string>
|
|
||||||
<string name="detail_refresh_message_error">Could not refresh topic: %1$s</string>
|
|
||||||
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
||||||
|
|
||||||
<!-- Detail activity: Action bar -->
|
<!-- Detail activity: Action bar -->
|
||||||
<string name="detail_menu_test">Send test notification</string>
|
<string name="detail_menu_test">Send test notification</string>
|
||||||
|
<string name="detail_menu_copy_url">Copy topic address</string>
|
||||||
<string name="detail_menu_refresh">Force refresh</string>
|
<string name="detail_menu_refresh">Force refresh</string>
|
||||||
<string name="detail_menu_unsubscribe">Unsubscribe</string>
|
<string name="detail_menu_unsubscribe">Unsubscribe</string>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue