Refactor: Remove NotificationEntity and use an in-memory cache

Removes the `NotificationEntity` and its associated database table (`notification`). This change simplifies the database schema and removes all related database queries from `ApplicationDao`.

Instead of persisting notification data, an in-memory `LruCache` is now used to prevent processing duplicate notification events. The notification subscription service now checks this cache to determine the `since` timestamp for new filter requests.
This commit is contained in:
greenart7c3
2025-10-22 13:26:26 -03:00
parent cc1f4ca5de
commit af21400ff2
10 changed files with 448 additions and 169 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "431c73ad09acac77a9c7d0edb1372e8b",
"identityHash": "f7d5a47e2a7bd4aad354df5a2a2796a4",
"entities": [
{
"tableName": "application",
@@ -207,56 +207,6 @@
}
]
},
{
"tableName": "notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_notification_eventId",
"unique": false,
"columnNames": [
"eventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notification_eventId` ON `${TABLE_NAME}` (`eventId`)"
},
{
"name": "index_notification_time",
"unique": false,
"columnNames": [
"time"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notification_time` ON `${TABLE_NAME}` (`time`)"
}
]
},
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `time` INTEGER NOT NULL, `accepted` INTEGER NOT NULL, FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@@ -468,7 +418,7 @@
],
"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, '431c73ad09acac77a9c7d0edb1372e8b')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f7d5a47e2a7bd4aad354df5a2a2796a4')"
]
}
}
@@ -0,0 +1,424 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "f7d5a47e2a7bd4aad354df5a2a2796a4",
"entities": [
{
"tableName": "application",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `relays` TEXT NOT NULL, `url` TEXT NOT NULL, `icon` TEXT NOT NULL, `description` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `isConnected` INTEGER NOT NULL, `secret` TEXT NOT NULL, `useSecret` INTEGER NOT NULL, `signPolicy` INTEGER NOT NULL, `closeApplication` INTEGER NOT NULL, `deleteAfter` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "relays",
"columnName": "relays",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isConnected",
"columnName": "isConnected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useSecret",
"columnName": "useSecret",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signPolicy",
"columnName": "signPolicy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "closeApplication",
"columnName": "closeApplication",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfter",
"columnName": "deleteAfter",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [
{
"name": "index_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_key` ON `${TABLE_NAME}` (`key`)"
},
{
"name": "index_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "applicationPermission",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `acceptable` INTEGER NOT NULL, `rememberType` INTEGER NOT NULL, `acceptUntil` INTEGER NOT NULL, `rejectUntil` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "acceptable",
"columnName": "acceptable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rememberType",
"columnName": "rememberType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "acceptUntil",
"columnName": "acceptUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rejectUntil",
"columnName": "rejectUntil",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "permissions_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `permissions_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "permissions_unique",
"unique": true,
"columnNames": [
"pkKey",
"type",
"kind"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `${TABLE_NAME}` (`pkKey`, `type`, `kind`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
},
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `time` INTEGER NOT NULL, `accepted` INTEGER NOT NULL, FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accepted",
"columnName": "accepted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "history_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "history_by_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_id` ON `${TABLE_NAME}` (`id`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
},
{
"tableName": "amber_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `message` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "history2",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `time` INTEGER NOT NULL, `accepted` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accepted",
"columnName": "accepted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "history_by_pk_key2",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_pk_key2` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "history_by_id2",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_id2` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "history_by_time",
"unique": false,
"columnNames": [
"time"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_time` ON `${TABLE_NAME}` (`time`)"
},
{
"name": "history_by_key_and_time",
"unique": false,
"columnNames": [
"pkKey",
"time"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `history_by_key_and_time` ON `${TABLE_NAME}` (`pkKey`, `time`)"
}
]
}
],
"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, 'f7d5a47e2a7bd4aad354df5a2a2796a4')"
]
}
}
@@ -5,6 +5,7 @@ import android.content.Intent
import android.net.NetworkCapabilities
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.LruCache
import androidx.compose.runtime.mutableStateOf
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.DefaultLifecycleObserver
@@ -120,6 +121,7 @@ class Amber : Application(), LifecycleObserver {
val isOnOfflineState = mutableStateOf(false)
private val isStartingApp = MutableStateFlow(false)
val isStartingAppState = isStartingApp
val notificationCache = LruCache<String, Long>(10)
fun isSocksProxyAlive(proxyHost: String, proxyPort: Int): Boolean {
try {
@@ -264,23 +266,6 @@ class Amber : Application(), LifecycleObserver {
client.disconnect()
}
// LocalPreferences.allSavedAccounts(this@Amber).forEach {
// List<Int>(100000, init = { 1 }).forEachIndexed { index, it2 ->
// launch(Dispatchers.IO) {
// getDatabase(it.npub).applicationDao().addHistory(
// HistoryEntity2(
// id = 0,
// pkKey = "com.vitorpamplona.amethyst",
// type = "SIGN_EVENT",
// kind = 1,
// time = TimeUtils.now() + index,
// accepted = true,
// )
// )
// }
// }
// }
launch(Dispatchers.Main) {
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
@@ -1,12 +1,14 @@
package com.greenart7c3.nostrsigner.database
import android.content.Context
import android.util.Log
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.greenart7c3.nostrsigner.Amber
import java.util.concurrent.Executors
val MIGRATION_1_2 =
@@ -100,16 +102,25 @@ val MIGRATION_10_11 = object : Migration(10, 11) {
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.execSQL("DROP TABLE notification")
} catch (e: Exception) {
Log.e(Amber.TAG, "No notification table", e)
}
}
}
@Database(
entities = [
ApplicationEntity::class,
ApplicationPermissionsEntity::class,
NotificationEntity::class,
HistoryEntity::class,
LogEntity::class,
HistoryEntity2::class,
],
version = 11,
version = 12,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -142,6 +153,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.build()
instance.openHelper.writableDatabase.execSQL("VACUUM")
@@ -8,7 +8,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.greenart7c3.nostrsigner.Amber
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.Flow
@Dao
@@ -16,27 +15,6 @@ interface ApplicationDao {
@Query("SELECT signPolicy FROM application WHERE `key` = :key")
fun getSignPolicy(key: String): Int?
@Query("SELECT MAX(time) FROM notification")
fun getLatestNotification(): Long?
@Query("SELECT * FROM notification WHERE eventId = :eventId")
suspend fun getNotification(eventId: String): NotificationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Transaction
suspend fun insertNotification(notificationEntity: NotificationEntity): Long? {
deleteNotification(TimeUtils.oneDayAgo())
return innerInsertNotification(notificationEntity)
}
@Query("DELETE FROM notification WHERE time <= :time")
@Transaction
suspend fun deleteNotification(time: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Transaction
suspend fun innerInsertNotification(notificationEntity: NotificationEntity): Long?
@Query("SELECT * FROM application where pubKey = :pubKey order by name")
suspend fun getAll(pubKey: String): List<ApplicationEntity>
@@ -219,18 +197,6 @@ interface ApplicationDao {
@Transaction
suspend fun deleteHistory(historyEntity: HistoryEntity2)
@Query("SELECT COUNT(*) FROM notification WHERE time < :time")
@Transaction
suspend fun countOldNotification(time: Long): Long
@Query("SELECT * FROM notification WHERE time <= :time ORDER BY TIME DESC LIMIT 100")
@Transaction
suspend fun getOldNotification(time: Long): List<NotificationEntity>
@Delete
@Transaction
suspend fun deleteNotification(notificationEntity: NotificationEntity)
@Query("SELECT COUNT(*) FROM amber_log WHERE time < :time")
@Transaction
suspend fun countOldLog(time: Long): Long
@@ -1,19 +0,0 @@
package com.greenart7c3.nostrsigner.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "notification",
indices = [
Index(value = ["eventId"]),
Index(value = ["time"]),
],
)
data class NotificationEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val eventId: String,
val time: Long,
)
@@ -33,20 +33,6 @@ class ClearLogsWorker(appContext: Context, workerParams: WorkerParameters) :
}
}
val countNotification = database.applicationDao().countOldNotification(oneWeekAgo)
Log.d(Amber.TAG, "Deleting $countNotification old notification entries")
if (countNotification > 0) {
var logs = database.applicationDao().getOldNotification(oneWeekAgo)
var count = 0
while (logs.isNotEmpty()) {
count++
logs.forEach { history ->
database.applicationDao().deleteNotification(history)
}
logs = database.applicationDao().getOldNotification(oneWeekAgo)
}
}
val countLog = database.applicationDao().countOldLog(oneWeek)
Log.d(Amber.TAG, "Deleting $countLog old log entries from ${com.greenart7c3.nostrsigner.models.TimeUtils.formatLongToCustomDateTimeWithSeconds(oneWeek)}")
if (countLog > 0) {
@@ -34,7 +34,6 @@ import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.database.HistoryEntity2
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.database.NotificationEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.EncryptionType
@@ -150,9 +149,10 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val responseRelay = listOf(relay)
val database = Amber.instance.getDatabase(acc.npub)
val dao = database.applicationDao()
val notification = dao.getNotification(event.id)
val notification = Amber.instance.notificationCache[event.id]
if (notification != null) return
dao.insertNotification(NotificationEntity(0, event.id, event.createdAt))
Amber.instance.notificationCache.put(event.id, event.createdAt)
dao.insertLog(
LogEntity(
@@ -66,25 +66,15 @@ class NotificationSubscription(
client.openReqSubscription(subId, createNotificationsFilter())
}
fun closeSubscription() {
client.close(subId)
}
private fun createNotificationsFilter(): Map<NormalizedRelayUrl, List<Filter>> {
// TODO: If you break relays per account, you can change this to only send the requests to the right relays for each account.
val relays = Amber.instance.getSavedRelays()
var since = TimeUtils.now()
val accounts = LocalPreferences.allSavedAccounts(appContext)
var localLatest = 0L
accounts.forEach {
val latest = Amber.instance.getDatabase(it.npub).applicationDao().getLatestNotification()
if (latest != null && latest > localLatest) {
localLatest = latest + 1
}
}
if (localLatest > 0) {
since = localLatest
val latest = if (Amber.instance.notificationCache.size() > 0) Amber.instance.notificationCache.snapshot().maxOf { it.value } else 0L
if (latest > 0) {
since = latest
}
val pubKeys = accounts.map { it.npub.bechToBytes().toHexKey() }
@@ -290,21 +290,6 @@ fun SettingsScreen(
}
}
val countNotification = database.applicationDao().countOldNotification(oneWeekAgo)
if (countNotification > 0) {
status = context.getString(R.string.deleting_old_notification_entries, countNotification)
var logs = database.applicationDao().getOldNotification(oneWeekAgo)
var count = 0
while (logs.isNotEmpty()) {
count++
status = context.getString(R.string.deleting_old_notification_entries_2, 100 * count, countNotification)
logs.forEach { history ->
database.applicationDao().deleteNotification(history)
}
logs = database.applicationDao().getOldNotification(oneWeekAgo)
}
}
val countLog = database.applicationDao().countOldLog(oneWeek)
if (countLog > 0) {
status = context.getString(R.string.deleting_old_log_entries, countLog)