feat: implement Android database layer with Room

- Add SubscriptionEntity, FeedItemEntity, SearchHistoryEntity
- Create SubscriptionDao, FeedItemDao, SearchHistoryDao with CRUD operations
- Implement RssDatabase with FTS5 virtual table for full-text search
- Add type converters for Date, String lists, and FeedItem lists
- Implement cascade delete for feed items when subscription is removed
- Add comprehensive unit tests for all DAOs
- Add database integration tests for entity round-trips and FTS
- Configure Room testing dependencies
This commit is contained in:
2026-03-29 20:41:51 -04:00
parent f0922e3c03
commit 473457df2f
15 changed files with 1328 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rssuper.converters.DateConverter
import com.rssuper.converters.FeedItemListConverter
import com.rssuper.converters.StringListConverter
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SearchHistoryEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Date
@Database(
entities = [
SubscriptionEntity::class,
FeedItemEntity::class,
SearchHistoryEntity::class
],
version = 1,
exportSchema = true
)
@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class)
abstract class RssDatabase : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun feedItemDao(): FeedItemDao
abstract fun searchHistoryDao(): SearchHistoryDao
companion object {
@Volatile
private var INSTANCE: RssDatabase? = null
fun getDatabase(context: Context): RssDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
RssDatabase::class.java,
"rss_database"
)
.addCallback(DatabaseCallback())
.build()
INSTANCE = instance
instance
}
}
}
private class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
INSTANCE?.let { database ->
CoroutineScope(Dispatchers.IO).launch {
createFTSVirtualTable(db)
}
}
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
createFTSVirtualTable(db)
}
private fun createFTSVirtualTable(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
title,
description,
content,
author,
content='feed_items',
contentless_delete=true
)
""".trimIndent())
}
}
}

View File

@@ -0,0 +1,77 @@
package com.rssuper.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.rssuper.database.entities.FeedItemEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface FeedItemDao {
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC")
fun getItemsBySubscription(subscriptionId: String): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE id = :id")
suspend fun getItemById(id: String): FeedItemEntity?
@Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC")
fun getItemsBySubscriptions(subscriptionIds: List<String>): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
fun getUnreadItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
fun getStarredItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
fun getUnreadCount(subscriptionId: String): Flow<Int>
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
fun getTotalUnreadCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: FeedItemEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
@Update
suspend fun updateItem(item: FeedItemEntity): Int
@Delete
suspend fun deleteItem(item: FeedItemEntity): Int
@Query("DELETE FROM feed_items WHERE id = :id")
suspend fun deleteItemById(id: String): Int
@Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId")
suspend fun deleteItemsBySubscription(subscriptionId: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE id = :id")
suspend fun markAsRead(id: String): Int
@Query("UPDATE feed_items SET isRead = 0 WHERE id = :id")
suspend fun markAsUnread(id: String): Int
@Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id")
suspend fun markAsStarred(id: String): Int
@Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id")
suspend fun markAsUnstarred(id: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId")
suspend fun markAllAsRead(subscriptionId: String): Int
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
}

View File

@@ -0,0 +1,49 @@
package com.rssuper.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.rssuper.database.entities.SearchHistoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchHistoryDao {
@Query("SELECT * FROM search_history ORDER BY timestamp DESC")
fun getAllSearchHistory(): Flow<List<SearchHistoryEntity>>
@Query("SELECT * FROM search_history WHERE id = :id")
suspend fun getSearchHistoryById(id: String): SearchHistoryEntity?
@Query("SELECT * FROM search_history WHERE query LIKE '%' || :query || '%' ORDER BY timestamp DESC")
fun searchHistory(query: String): Flow<List<SearchHistoryEntity>>
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentSearches(limit: Int): Flow<List<SearchHistoryEntity>>
@Query("SELECT COUNT(*) FROM search_history")
fun getSearchHistoryCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistory(search: SearchHistoryEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistories(searches: List<SearchHistoryEntity>): List<Long>
@Update
suspend fun updateSearchHistory(search: SearchHistoryEntity): Int
@Delete
suspend fun deleteSearchHistory(search: SearchHistoryEntity): Int
@Query("DELETE FROM search_history WHERE id = :id")
suspend fun deleteSearchHistoryById(id: String): Int
@Query("DELETE FROM search_history")
suspend fun deleteAllSearchHistory(): Int
@Query("DELETE FROM search_history WHERE timestamp < :timestamp")
suspend fun deleteSearchHistoryOlderThan(timestamp: Long): Int
}

View File

@@ -0,0 +1,56 @@
package com.rssuper.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY title ASC")
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE id = :id")
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE url = :url")
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC")
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
@Update
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
@Delete
suspend fun deleteSubscription(subscription: SubscriptionEntity): Int
@Query("DELETE FROM subscriptions WHERE id = :id")
suspend fun deleteSubscriptionById(id: String): Int
@Query("SELECT COUNT(*) FROM subscriptions")
fun getSubscriptionCount(): Flow<Int>
@Query("UPDATE subscriptions SET error = :error WHERE id = :id")
suspend fun updateError(id: String, error: String?)
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date)
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
}

View File

@@ -0,0 +1,57 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.Date
@Entity(
tableName = "feed_items",
foreignKeys = [
ForeignKey(
entity = SubscriptionEntity::class,
parentColumns = ["id"],
childColumns = ["subscriptionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["subscriptionId"]),
Index(value = ["published"])
]
)
data class FeedItemEntity(
@PrimaryKey
val id: String,
val subscriptionId: String,
val title: String,
val link: String? = null,
val description: String? = null,
val content: String? = null,
val author: String? = null,
val published: Date? = null,
val updated: Date? = null,
val categories: String? = null,
val enclosureUrl: String? = null,
val enclosureType: String? = null,
val enclosureLength: Long? = null,
val guid: String? = null,
val isRead: Boolean = false,
val isStarred: Boolean = false
)

View File

@@ -0,0 +1,19 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.Date
@Entity(
tableName = "search_history",
indices = [Index(value = ["timestamp"])]
)
data class SearchHistoryEntity(
@PrimaryKey
val id: String,
val query: String,
val timestamp: Date
)

View File

@@ -0,0 +1,54 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.rssuper.models.HttpAuth
import java.util.Date
@Entity(
tableName = "subscriptions",
indices = [Index(value = ["url"], unique = true)]
)
data class SubscriptionEntity(
@PrimaryKey
val id: String,
val url: String,
val title: String,
val category: String? = null,
val enabled: Boolean = true,
val fetchInterval: Long = 3600000,
val createdAt: Date,
val updatedAt: Date,
val lastFetchedAt: Date? = null,
val nextFetchAt: Date? = null,
val error: String? = null,
val httpAuthUsername: String? = null,
val httpAuthPassword: String? = null
) {
fun toHttpAuth(): HttpAuth? {
return if (httpAuthUsername != null && httpAuthPassword != null) {
HttpAuth(httpAuthUsername, httpAuthPassword)
} else null
}
fun fromHttpAuth(auth: HttpAuth?): SubscriptionEntity {
return copy(
httpAuthUsername = auth?.username,
httpAuthPassword = auth?.password
)
}
}