Remove denormalized subscription, add schema migration for sqlite db

This commit is contained in:
Philipp Heckel 2021-11-11 22:14:28 -05:00
parent 72d7a2f93d
commit 86738d5441
13 changed files with 265 additions and 61 deletions

View file

@ -16,6 +16,14 @@ android {
versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
/* Required for Room schema migrations */
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
buildTypes {

View 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')"
]
}
}

View file

@ -1,8 +1,11 @@
package io.heckel.ntfy.data
import android.content.Context
import androidx.annotation.NonNull
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
@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
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "notifications") val notifications: Int,
@ColumnInfo(name = "lastActive") val lastActive: Long, // Unix timestamp
@Ignore val notifications: Int,
@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
@ -19,10 +32,11 @@ data class Notification(
@PrimaryKey val id: String,
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@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 fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@ -35,45 +49,81 @@ abstract class Database : RoomDatabase() {
return instance ?: synchronized(this) {
val instance = Room
.databaseBuilder(context.applicationContext, Database::class.java,"AppDatabase")
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build()
this.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
interface SubscriptionDao {
@Query("SELECT * FROM subscription ORDER BY lastActive DESC")
fun listFlow(): Flow<List<Subscription>>
@Query(
"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")
fun list(): List<Subscription>
@Query(
"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")
fun get(baseUrl: String, topic: String): Subscription?
@Query(
"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")
fun get(subscriptionId: Long): Subscription?
@Query(
"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
fun add(subscription: Subscription)
@Update
fun update(subscription: Subscription)
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
fun remove(subscriptionId: Long)
}
@Dao
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>>
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId")
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
fun listIds(subscriptionId: Long): List<String>
@Insert(onConflict = OnConflictStrategy.IGNORE)
@ -82,7 +132,7 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE id = :notificationId")
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)
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")

View file

@ -4,7 +4,8 @@ import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
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) {
init {
@ -12,17 +13,20 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
}
fun getSubscriptionsLiveData(): LiveData<List<Subscription>> {
return subscriptionDao.listFlow().asLiveData()
return subscriptionDao
.listFlow()
.asLiveData()
.map { list -> toSubscriptionList(list) }
}
fun getSubscriptions(): List<Subscription> {
return subscriptionDao.list()
return toSubscriptionList(subscriptionDao.list())
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
return subscriptionDao.get(baseUrl, topic)
return toSubscription(subscriptionDao.get(baseUrl, topic))
}
@Suppress("RedundantSuspendModifier")
@ -31,12 +35,6 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
subscriptionDao.add(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun updateSubscription(subscription: Subscription) {
subscriptionDao.update(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun removeSubscription(subscriptionId: Long) {
@ -54,24 +52,16 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun addNotification(subscriptionId: Long, notification: Notification) {
suspend fun addNotification(notification: Notification) {
val maybeExistingNotification = notificationDao.get(notification.id)
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)
if (maybeExistingNotification == null) {
notificationDao.add(notification)
}
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun removeNotification(subscriptionId: Long, notificationId: String) {
val subscription = subscriptionDao.get(subscriptionId) ?: return
val newSubscription = subscription.copy(notifications = subscription.notifications - 1, lastActive = Date().time/1000)
subscriptionDao.update(newSubscription)
suspend fun removeNotification(notificationId: String) {
notificationDao.remove(notificationId)
}
@ -81,6 +71,31 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
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 {
private val TAG = "NtfyRepository"
private var instance: Repository? = null

View file

@ -53,7 +53,7 @@ class ApiService {
private fun fromString(subscriptionId: Long, s: String): Notification {
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(

View file

@ -39,8 +39,8 @@ class FirebaseService : FirebaseMessagingService() {
// Add notification
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message)
repository.addNotification(subscription.id, notification)
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false)
repository.addNotification(notification)
// Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")

View file

@ -23,6 +23,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -113,6 +114,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
onRefreshClick()
true
}
R.id.detail_menu_copy_url -> {
onCopyUrlClick()
true
}
R.id.detail_menu_unsubscribe -> {
onDeleteClick()
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() {
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 newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.detail_refresh_message_no_results)
getString(R.string.refresh_message_no_results)
} 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() }
} catch (e: Exception) {
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()
}
}
@ -243,7 +260,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
builder
.setMessage(R.string.detail_action_mode_delete_dialog_message)
.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()
}
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->

View file

@ -14,8 +14,8 @@ class DetailViewModel(private val repository: Repository) : ViewModel() {
return repository.getNotificationsLiveData(subscriptionId)
}
fun remove(subscriptionId: Long, notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.removeNotification(subscriptionId, notificationId)
fun remove(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.removeNotification(notificationId)
}
}

View file

@ -76,8 +76,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
mainList.adapter = adapter
viewModel.list().observe(this) {
it?.let {
adapter.submitList(it as MutableList<Subscription>)
it?.let { subscriptions ->
adapter.submitList(subscriptions as MutableList<Subscription>)
if (it.isEmpty()) {
mainList.visibility = View.GONE
noEntries.visibility = View.VISIBLE
@ -168,7 +168,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
lifecycleScope.launch(Dispatchers.IO) {
try {
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) {
Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}")
}
@ -196,19 +196,27 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
lifecycleScope.launch(Dispatchers.IO) {
try {
Log.d(TAG, "Polling for new notifications")
var newNotificationsCount = 0
repository.getSubscriptions().forEach { subscription ->
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
repository.addNotification(subscription.id, notification)
repository.addNotification(notification)
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")
} catch (e: Exception) {
Log.e(TAG, "Polling failed: ${e.message}", e)
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()
}
}
}

View file

@ -56,10 +56,12 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} else {
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))
} 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)
statusView.text = statusMessage

View file

@ -31,7 +31,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
repository.addNotification(subscription.id, notification)
repository.addNotification(notification)
notifier.send(subscription, notification.message)
}
}

View file

@ -1,5 +1,6 @@
<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_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"/>
</menu>

View file

@ -7,6 +7,11 @@
<string name="notification_channel_name">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 -->
<string name="main_action_bar_title">Subscribed topics</string>
<string name="main_menu_refresh">Force refresh</string>
@ -46,13 +51,11 @@
<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_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>
<!-- Detail activity: Action bar -->
<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_unsubscribe">Unsubscribe</string>