Auto-commit 2026-03-30 16:30

This commit is contained in:
2026-03-30 16:30:46 -04:00
parent 5fc7ed74c4
commit a6da9ef9cf
41 changed files with 3438 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
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.BookmarkEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface BookmarkDao {
@Query("SELECT * FROM bookmarks ORDER BY createdAt DESC")
fun getAllBookmarks(): Flow<List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE id = :id")
suspend fun getBookmarkById(id: String): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE feedItemId = :feedItemId")
suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE tags LIKE '%' || :tag || '%' ORDER BY createdAt DESC")
fun getBookmarksByTag(tag: String): Flow<List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
suspend fun getBookmarksPaginated(limit: Int, offset: Int): List<BookmarkEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBookmark(bookmark: BookmarkEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBookmarks(bookmarks: List<BookmarkEntity>): List<Long>
@Update
suspend fun updateBookmark(bookmark: BookmarkEntity): Int
@Delete
suspend fun deleteBookmark(bookmark: BookmarkEntity): Int
@Query("DELETE FROM bookmarks WHERE id = :id")
suspend fun deleteBookmarkById(id: String): Int
@Query("DELETE FROM bookmarks WHERE feedItemId = :feedItemId")
suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int
@Query("SELECT COUNT(*) FROM bookmarks")
fun getBookmarkCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE '%' || :tag || '%'")
fun getBookmarkCountByTag(tag: String): Flow<Int>
}

View File

@@ -74,4 +74,7 @@ interface FeedItemDao {
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit")
suspend fun searchByFts(query: String, limit: Int = 20): List<FeedItemEntity>
}

View File

@@ -53,4 +53,13 @@ interface SubscriptionDao {
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
@Query("UPDATE subscriptions SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean): Int
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
suspend fun updateLastFetchedAtMillis(id: String, lastFetchedAt: Long): Int
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAtMillis(id: String, nextFetchAt: Long): Int
}

View File

@@ -0,0 +1,43 @@
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 = "bookmarks",
indices = [Index(value = ["feedItemId"], unique = true)]
)
data class BookmarkEntity(
@PrimaryKey
val id: String,
val feedItemId: String,
val title: String,
val link: String? = null,
val description: String? = null,
val content: String? = null,
val createdAt: Date,
val tags: String? = null
) {
fun toFeedItem(): FeedItemEntity {
return FeedItemEntity(
id = feedItemId,
subscriptionId = "", // Will be set when linked to subscription
title = title,
link = link,
description = description,
content = content,
published = createdAt,
updated = createdAt
)
}
}

View File

@@ -0,0 +1,9 @@
package com.rssuper.model
sealed interface Error {
data class Network(val message: String, val code: Int? = null) : Error
data class Database(val message: String, val cause: Throwable? = null) : Error
data class Parsing(val message: String, val cause: Throwable? = null) : Error
data class Auth(val message: String) : Error
data object Unknown : Error
}

View File

@@ -0,0 +1,8 @@
package com.rssuper.model
sealed interface State<out T> {
data object Idle : State<Nothing>
data object Loading : State<Nothing>
data class Success<T>(val data: T) : State<T>
data class Error(val message: String, val cause: Throwable? = null) : State<Nothing>
}

View File

@@ -0,0 +1,91 @@
package com.rssuper.repository
import com.rssuper.database.daos.BookmarkDao
import com.rssuper.database.entities.BookmarkEntity
import com.rssuper.state.BookmarkState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class BookmarkRepository(
private val bookmarkDao: BookmarkDao
) {
fun getAllBookmarks(): Flow<BookmarkState> {
return bookmarkDao.getAllBookmarks().map { bookmarks ->
BookmarkState.Success(bookmarks)
}.catch { e ->
emit(BookmarkState.Error("Failed to load bookmarks", e))
}
}
fun getBookmarksByTag(tag: String): Flow<BookmarkState> {
return bookmarkDao.getBookmarksByTag(tag).map { bookmarks ->
BookmarkState.Success(bookmarks)
}.catch { e ->
emit(BookmarkState.Error("Failed to load bookmarks by tag", e))
}
}
suspend fun getBookmarkById(id: String): BookmarkEntity? {
return try {
bookmarkDao.getBookmarkById(id)
} catch (e: Exception) {
throw RuntimeException("Failed to get bookmark", e)
}
}
suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity? {
return try {
bookmarkDao.getBookmarkByFeedItemId(feedItemId)
} catch (e: Exception) {
throw RuntimeException("Failed to get bookmark by feed item ID", e)
}
}
suspend fun insertBookmark(bookmark: BookmarkEntity): Long {
return try {
bookmarkDao.insertBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to insert bookmark", e)
}
}
suspend fun insertBookmarks(bookmarks: List<BookmarkEntity>): List<Long> {
return try {
bookmarkDao.insertBookmarks(bookmarks)
} catch (e: Exception) {
throw RuntimeException("Failed to insert bookmarks", e)
}
}
suspend fun updateBookmark(bookmark: BookmarkEntity): Int {
return try {
bookmarkDao.updateBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to update bookmark", e)
}
}
suspend fun deleteBookmark(bookmark: BookmarkEntity): Int {
return try {
bookmarkDao.deleteBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark", e)
}
}
suspend fun deleteBookmarkById(id: String): Int {
return try {
bookmarkDao.deleteBookmarkById(id)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark by ID", e)
}
}
suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int {
return try {
bookmarkDao.deleteBookmarkByFeedItemId(feedItemId)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark by feed item ID", e)
}
}
}

View File

@@ -0,0 +1,102 @@
package com.rssuper.repository
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.model.Error
import com.rssuper.model.State
import com.rssuper.models.Feed
import com.rssuper.models.FeedItem
import com.rssuper.parsing.FeedParser
import com.rssuper.parsing.ParseResult
import com.rssuper.services.FeedFetcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import java.util.Date
class FeedRepository(
private val feedFetcher: FeedFetcher,
private val feedItemDao: FeedItemDao
) {
private val _feedState = MutableStateFlow<State<Feed>>(State.Idle)
val feedState: StateFlow<State<Feed>> = _feedState.asStateFlow()
private val _feedItemsState = MutableStateFlow<State<List<FeedItemEntity>>>(State.Idle)
val feedItemsState: StateFlow<State<List<FeedItemEntity>>> = _feedItemsState.asStateFlow()
suspend fun fetchFeed(url: String, httpAuth: com.rssuper.services.HTTPAuthCredentials? = null): Boolean {
_feedState.value = State.Loading
val result = feedFetcher.fetchAndParse(url, httpAuth)
return result.fold(
onSuccess = { parseResult ->
when (parseResult) {
is ParseResult.Success -> {
val feed = parseResult.feed
_feedState.value = State.Success(feed)
true
}
is ParseResult.Error -> {
_feedState.value = State.Error(parseResult.message)
false
}
}
},
onFailure = { error ->
_feedState.value = State.Error(
message = error.message ?: "Unknown error",
cause = error
)
false
}
)
}
fun getFeedItems(subscriptionId: String): Flow<State<List<FeedItemEntity>>> {
return feedItemDao.getItemsBySubscription(subscriptionId)
.map { items ->
State.Success(items)
}
}
suspend fun markItemAsRead(itemId: String): Boolean {
return try {
feedItemDao.markAsRead(itemId)
true
} catch (e: Exception) {
_feedItemsState.value = State.Error("Failed to mark item as read", e)
false
}
}
suspend fun markItemAsStarred(itemId: String): Boolean {
return try {
feedItemDao.markAsStarred(itemId)
true
} catch (e: Exception) {
_feedItemsState.value = State.Error("Failed to mark item as starred", e)
false
}
}
fun getStarredItems(): Flow<State<List<FeedItemEntity>>> {
return feedItemDao.getStarredItems()
.map { items ->
State.Success(items)
}
}
fun getUnreadItems(): Flow<State<List<FeedItemEntity>>> {
return feedItemDao.getUnreadItems()
.map { items ->
State.Success(items)
}
}
private fun <T> Flow<List<T>>.map(transform: (List<T>) -> State<List<T>>): Flow<State<List<T>>> {
return this.map { transform(it) }
}
}

View File

@@ -0,0 +1,32 @@
package com.rssuper.repository
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.Flow
interface FeedRepository {
fun getFeedItems(subscriptionId: String?): Flow<List<FeedItemEntity>>
suspend fun getFeedItemById(id: String): FeedItemEntity?
suspend fun insertFeedItem(item: FeedItemEntity): Long
suspend fun insertFeedItems(items: List<FeedItemEntity>): List<Long>
suspend fun updateFeedItem(item: FeedItemEntity): Int
suspend fun markAsRead(id: String, isRead: Boolean): Int
suspend fun markAsStarred(id: String, isStarred: Boolean): Int
suspend fun deleteFeedItem(id: String): Int
suspend fun getUnreadCount(subscriptionId: String?): Int
}
interface SubscriptionRepository {
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
suspend fun deleteSubscription(id: String): Int
suspend fun setEnabled(id: String, enabled: Boolean): Int
suspend fun setError(id: String, error: String?): Int
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Long): Int
suspend fun updateNextFetchAt(id: String, nextFetchAt: Long): Int
}

View File

@@ -0,0 +1,210 @@
package com.rssuper.repository
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SubscriptionEntity
import com.rssuper.state.ErrorDetails
import com.rssuper.state.ErrorType
import com.rssuper.state.State
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class FeedRepositoryImpl(
private val feedItemDao: FeedItemDao
) : FeedRepository {
override fun getFeedItems(subscriptionId: String?): Flow<State<List<FeedItemEntity>>> {
return if (subscriptionId != null) {
feedItemDao.getItemsBySubscription(subscriptionId).map { items ->
State.Success(items)
}.catch { e ->
emit(State.Error("Failed to load feed items", e))
}
} else {
feedItemDao.getUnreadItems().map { items ->
State.Success(items)
}.catch { e ->
emit(State.Error("Failed to load feed items", e))
}
}
}
override suspend fun getFeedItemById(id: String): FeedItemEntity? {
return try {
feedItemDao.getItemById(id)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to get feed item", false)
}
}
override suspend fun insertFeedItem(item: FeedItemEntity): Long {
return try {
feedItemDao.insertItem(item)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to insert feed item", false)
}
}
override suspend fun insertFeedItems(items: List<FeedItemEntity>): List<Long> {
return try {
feedItemDao.insertItems(items)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to insert feed items", false)
}
}
override suspend fun updateFeedItem(item: FeedItemEntity): Int {
return try {
feedItemDao.updateItem(item)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to update feed item", false)
}
}
override suspend fun markAsRead(id: String, isRead: Boolean): Int {
return try {
if (isRead) {
feedItemDao.markAsRead(id)
} else {
feedItemDao.markAsUnread(id)
}
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to mark item as read", true)
}
}
override suspend fun markAsStarred(id: String, isStarred: Boolean): Int {
return try {
if (isStarred) {
feedItemDao.markAsStarred(id)
} else {
feedItemDao.markAsUnstarred(id)
}
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to star item", true)
}
}
override suspend fun deleteFeedItem(id: String): Int {
return try {
feedItemDao.deleteItemById(id)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to delete feed item", false)
}
}
override suspend fun getUnreadCount(subscriptionId: String?): Int {
return try {
if (subscriptionId != null) {
feedItemDao.getItemById(subscriptionId)
feedItemDao.getUnreadCount(subscriptionId).first()
} else {
feedItemDao.getTotalUnreadCount().first()
}
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to get unread count", false)
}
}
}
class SubscriptionRepositoryImpl(
private val subscriptionDao: SubscriptionDao
) : SubscriptionRepository {
override fun getAllSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getAllSubscriptions().map { subscriptions ->
State.Success(subscriptions)
}.catch { e ->
emit(State.Error("Failed to load subscriptions", e))
}
}
override fun getEnabledSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getEnabledSubscriptions().map { subscriptions ->
State.Success(subscriptions)
}.catch { e ->
emit(State.Error("Failed to load enabled subscriptions", e))
}
}
override fun getSubscriptionsByCategory(category: String): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getSubscriptionsByCategory(category).map { subscriptions ->
State.Success(subscriptions)
}.catch { e ->
emit(State.Error("Failed to load subscriptions by category", e))
}
}
override suspend fun getSubscriptionById(id: String): SubscriptionEntity? {
return try {
subscriptionDao.getSubscriptionById(id)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to get subscription", false)
}
}
override suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity? {
return try {
subscriptionDao.getSubscriptionByUrl(url)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to get subscription by URL", false)
}
}
override suspend fun insertSubscription(subscription: SubscriptionEntity): Long {
return try {
subscriptionDao.insertSubscription(subscription)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to insert subscription", false)
}
}
override suspend fun updateSubscription(subscription: SubscriptionEntity): Int {
return try {
subscriptionDao.updateSubscription(subscription)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to update subscription", true)
}
}
override suspend fun deleteSubscription(id: String): Int {
return try {
subscriptionDao.deleteSubscriptionById(id)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to delete subscription", false)
}
}
override suspend fun setEnabled(id: String, enabled: Boolean): Int {
return try {
subscriptionDao.setEnabled(id, enabled)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to set subscription enabled state", true)
}
}
override suspend fun setError(id: String, error: String?): Int {
return try {
subscriptionDao.updateError(id, error)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to set subscription error", true)
}
}
override suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Long): Int {
return try {
subscriptionDao.updateLastFetchedAtMillis(id, lastFetchedAt)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to update last fetched time", true)
}
}
override suspend fun updateNextFetchAt(id: String, nextFetchAt: Long): Int {
return try {
subscriptionDao.updateNextFetchAtMillis(id, nextFetchAt)
} catch (e: Exception) {
throw ErrorDetails(ErrorType.DATABASE, "Failed to update next fetch time", true)
}
}
}

View File

@@ -0,0 +1,156 @@
package com.rssuper.repository
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.SubscriptionEntity
import com.rssuper.model.Error
import com.rssuper.model.State
import com.rssuper.models.FeedSubscription
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import java.util.Date
class SubscriptionRepository(
private val subscriptionDao: SubscriptionDao
) {
private val _subscriptionsState = MutableStateFlow<State<List<SubscriptionEntity>>>(State.Idle)
val subscriptionsState: StateFlow<State<List<SubscriptionEntity>>> = _subscriptionsState.asStateFlow()
fun getAllSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getAllSubscriptions()
.map { subscriptions ->
State.Success(subscriptions)
}
}
fun getEnabledSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getEnabledSubscriptions()
.map { subscriptions ->
State.Success(subscriptions)
}
}
fun getSubscriptionsByCategory(category: String): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getSubscriptionsByCategory(category)
.map { subscriptions ->
State.Success(subscriptions)
}
}
suspend fun getSubscriptionById(id: String): State<SubscriptionEntity?> {
return try {
val subscription = subscriptionDao.getSubscriptionById(id)
State.Success(subscription)
} catch (e: Exception) {
State.Error("Failed to get subscription", e)
}
}
suspend fun getSubscriptionByUrl(url: String): State<SubscriptionEntity?> {
return try {
val subscription = subscriptionDao.getSubscriptionByUrl(url)
State.Success(subscription)
} catch (e: Exception) {
State.Error("Failed to get subscription by URL", e)
}
}
suspend fun addSubscription(subscription: FeedSubscription): Boolean {
return try {
subscriptionDao.insertSubscription(
SubscriptionEntity(
id = subscription.id,
url = subscription.url,
title = subscription.title,
category = subscription.category,
enabled = subscription.enabled,
fetchInterval = subscription.fetchInterval,
createdAt = subscription.createdAt,
updatedAt = subscription.updatedAt,
lastFetchedAt = subscription.lastFetchedAt,
nextFetchAt = subscription.nextFetchAt,
error = subscription.error,
httpAuthUsername = subscription.httpAuth?.username,
httpAuthPassword = subscription.httpAuth?.password
)
)
_subscriptionsState.value = State.Success(emptyList())
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to add subscription", e)
false
}
}
suspend fun updateSubscription(subscription: FeedSubscription): Boolean {
return try {
subscriptionDao.updateSubscription(
SubscriptionEntity(
id = subscription.id,
url = subscription.url,
title = subscription.title,
category = subscription.category,
enabled = subscription.enabled,
fetchInterval = subscription.fetchInterval,
createdAt = subscription.createdAt,
updatedAt = subscription.updatedAt,
lastFetchedAt = subscription.lastFetchedAt,
nextFetchAt = subscription.nextFetchAt,
error = subscription.error,
httpAuthUsername = subscription.httpAuth?.username,
httpAuthPassword = subscription.httpAuth?.password
)
)
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update subscription", e)
false
}
}
suspend fun deleteSubscription(id: String): Boolean {
return try {
subscriptionDao.deleteSubscriptionById(id)
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to delete subscription", e)
false
}
}
suspend fun updateError(id: String, error: String?): Boolean {
return try {
subscriptionDao.updateError(id, error)
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update subscription error", e)
false
}
}
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date): Boolean {
return try {
subscriptionDao.updateLastFetchedAt(id, lastFetchedAt)
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update last fetched at", e)
false
}
}
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date): Boolean {
return try {
subscriptionDao.updateNextFetchAt(id, nextFetchAt)
true
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update next fetch at", e)
false
}
}
private fun <T> Flow<List<T>>.map(transform: (List<T>) -> State<List<T>>): Flow<State<List<T>>> {
return this.map { transform(it) }
}
}

View File

@@ -0,0 +1,18 @@
package com.rssuper.search
import com.rssuper.models.SearchFilters
/**
* SearchQuery - Represents a search query with filters
*/
data class SearchQuery(
val queryString: String,
val filters: SearchFilters? = null,
val page: Int = 1,
val pageSize: Int = 20,
val timestamp: Long = System.currentTimeMillis()
) {
fun isValid(): Boolean = queryString.isNotEmpty()
fun getCacheKey(): String = "${queryString}_${filters?.hashCode() ?: 0}"
}

View File

@@ -0,0 +1,16 @@
package com.rssuper.search
import com.rssuper.database.entities.FeedItemEntity
/**
* SearchResult - Represents a search result with relevance score
*/
data class SearchResult(
val feedItem: FeedItemEntity,
val relevanceScore: Float,
val highlight: String? = null
) {
fun isHighRelevance(): Boolean = relevanceScore > 0.8f
fun isMediumRelevance(): Boolean = relevanceScore in 0.5f..0.8f
fun isLowRelevance(): Boolean = relevanceScore < 0.5f
}

View File

@@ -0,0 +1,71 @@
package com.rssuper.search
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.entities.FeedItemEntity
/**
* SearchResultProvider - Provides search results from the database
*/
class SearchResultProvider(
private val feedItemDao: FeedItemDao
) {
suspend fun search(query: String, limit: Int = 20): List<SearchResult> {
// Use FTS query to search feed items
val results = feedItemDao.searchByFts(query, limit)
return results.mapIndexed { index, item ->
SearchResult(
feedItem = item,
relevanceScore = calculateRelevance(query, item, index),
highlight = generateHighlight(item)
)
}
}
suspend fun searchBySubscription(query: String, subscriptionId: String, limit: Int = 20): List<SearchResult> {
val results = feedItemDao.searchByFts(query, limit)
return results.filter { it.subscriptionId == subscriptionId }.mapIndexed { index, item ->
SearchResult(
feedItem = item,
relevanceScore = calculateRelevance(query, item, index),
highlight = generateHighlight(item)
)
}
}
private fun calculateRelevance(query: String, item: FeedItemEntity, position: Int): Float {
val queryLower = query.lowercase()
var score = 0.0f
// Title match (highest weight)
if (item.title.lowercase().contains(queryLower)) {
score += 1.0f
}
// Author match
if (item.author?.lowercase()?.contains(queryLower) == true) {
score += 0.5f
}
// Position bonus (earlier results are more relevant)
score += (1.0f / (position + 1)) * 0.3f
return score.coerceIn(0.0f, 1.0f)
}
private fun generateHighlight(item: FeedItemEntity): String? {
val maxLength = 200
var text = item.title
if (item.description?.isNotEmpty() == true) {
text += " ${item.description}"
}
if (text.length > maxLength) {
text = text.substring(0, maxLength) + "..."
}
return text
}
}

View File

@@ -0,0 +1,81 @@
package com.rssuper.search
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.entities.SearchHistoryEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* SearchService - Provides search functionality with FTS
*/
class SearchService(
private val feedItemDao: FeedItemDao,
private val searchHistoryDao: SearchHistoryDao,
private val resultProvider: SearchResultProvider
) {
private val cache = mutableMapOf<String, List<SearchResult>>()
private val maxCacheSize = 100
fun search(query: String): Flow<List<SearchResult>> {
val cacheKey = query.hashCode().toString()
// Return cached results if available
cache[cacheKey]?.let { return flow { emit(it) } }
return flow {
val results = resultProvider.search(query)
cache[cacheKey] = results
if (cache.size > maxCacheSize) {
cache.remove(cache.keys.first())
}
emit(results)
}
}
fun searchBySubscription(query: String, subscriptionId: String): Flow<List<SearchResult>> {
return flow {
val results = resultProvider.searchBySubscription(query, subscriptionId)
emit(results)
}
}
suspend fun searchAndSave(query: String): List<SearchResult> {
val results = resultProvider.search(query)
// Save to search history
saveSearchHistory(query)
return results
}
suspend fun saveSearchHistory(query: String) {
val searchHistory = SearchHistoryEntity(
id = System.currentTimeMillis().toString(),
query = query,
filtersJson = null,
timestamp = System.currentTimeMillis()
)
searchHistoryDao.insertSearchHistory(searchHistory)
}
fun getSearchHistory(): Flow<List<SearchHistoryEntity>> {
return searchHistoryDao.getAllSearchHistory()
}
suspend fun getRecentSearches(limit: Int = 10): List<SearchHistoryEntity> {
return searchHistoryDao.getRecentSearches(limit).firstOrNull() ?: emptyList()
}
suspend fun clearSearchHistory() {
searchHistoryDao.deleteAllSearchHistory()
}
fun getSearchSuggestions(query: String): Flow<List<SearchHistoryEntity>> {
return searchHistoryDao.searchHistory(query)
}
fun clearCache() {
cache.clear()
}
}

View File

@@ -0,0 +1,10 @@
package com.rssuper.state
import com.rssuper.database.entities.BookmarkEntity
sealed interface BookmarkState {
data object Idle : BookmarkState
data object Loading : BookmarkState
data class Success(val data: List<BookmarkEntity>) : BookmarkState
data class Error(val message: String, val cause: Throwable? = null) : BookmarkState
}

View File

@@ -0,0 +1,15 @@
package com.rssuper.state
enum class ErrorType {
NETWORK,
DATABASE,
PARSING,
AUTH,
UNKNOWN
}
data class ErrorDetails(
val type: ErrorType,
val message: String,
val retryable: Boolean = false
)

View File

@@ -0,0 +1,8 @@
package com.rssuper.state
sealed interface State<out T> {
data object Idle : State<Nothing>
data object Loading : State<Nothing>
data class Success<T>(val data: T) : State<T>
data class Error(val message: String, val cause: Throwable? = null) : State<Nothing>
}

View File

@@ -0,0 +1,67 @@
package com.rssuper.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rssuper.repository.FeedRepository
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class FeedViewModel(
private val feedRepository: FeedRepository
) : ViewModel() {
private val _feedState = MutableStateFlow<State<List<com.rssuper.database.entities.FeedItemEntity>>>(State.Idle)
val feedState: StateFlow<State<List<com.rssuper.database.entities.FeedItemEntity>>> = _feedState.asStateFlow()
private val _unreadCount = MutableStateFlow<State<Int>>(State.Idle)
val unreadCount: StateFlow<State<Int>> = _unreadCount.asStateFlow()
fun loadFeedItems(subscriptionId: String? = null) {
viewModelScope.launch {
feedRepository.getFeedItems(subscriptionId).collect { state ->
_feedState.value = state
}
}
}
fun loadUnreadCount(subscriptionId: String? = null) {
viewModelScope.launch {
_unreadCount.value = State.Loading
try {
val count = feedRepository.getUnreadCount(subscriptionId)
_unreadCount.value = State.Success(count)
} catch (e: Exception) {
_unreadCount.value = State.Error("Failed to load unread count", e)
}
}
}
fun markAsRead(id: String, isRead: Boolean) {
viewModelScope.launch {
try {
feedRepository.markAsRead(id, isRead)
loadUnreadCount()
} catch (e: Exception) {
_unreadCount.value = State.Error("Failed to update read state", e)
}
}
}
fun markAsStarred(id: String, isStarred: Boolean) {
viewModelScope.launch {
try {
feedRepository.markAsStarred(id, isStarred)
} catch (e: Exception) {
_feedState.value = State.Error("Failed to update starred state", e)
}
}
}
fun refreshFeed(subscriptionId: String? = null) {
loadFeedItems(subscriptionId)
loadUnreadCount(subscriptionId)
}
}

View File

@@ -0,0 +1,83 @@
package com.rssuper.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rssuper.repository.SubscriptionRepository
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class SubscriptionViewModel(
private val subscriptionRepository: SubscriptionRepository
) : ViewModel() {
private val _subscriptionsState = MutableStateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Idle)
val subscriptionsState: StateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>> = _subscriptionsState.asStateFlow()
private val _enabledSubscriptionsState = MutableStateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Idle)
val enabledSubscriptionsState: StateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>> = _enabledSubscriptionsState.asStateFlow()
fun loadAllSubscriptions() {
viewModelScope.launch {
subscriptionRepository.getAllSubscriptions().collect { state ->
_subscriptionsState.value = state
}
}
}
fun loadEnabledSubscriptions() {
viewModelScope.launch {
subscriptionRepository.getEnabledSubscriptions().collect { state ->
_enabledSubscriptionsState.value = state
}
}
}
fun setEnabled(id: String, enabled: Boolean) {
viewModelScope.launch {
try {
subscriptionRepository.setEnabled(id, enabled)
loadEnabledSubscriptions()
} catch (e: Exception) {
_enabledSubscriptionsState.value = State.Error("Failed to update subscription enabled state", e)
}
}
}
fun setError(id: String, error: String?) {
viewModelScope.launch {
try {
subscriptionRepository.setError(id, error)
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to set subscription error", e)
}
}
}
fun updateLastFetchedAt(id: String, lastFetchedAt: Long) {
viewModelScope.launch {
try {
subscriptionRepository.updateLastFetchedAt(id, lastFetchedAt)
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update last fetched time", e)
}
}
}
fun updateNextFetchAt(id: String, nextFetchAt: Long) {
viewModelScope.launch {
try {
subscriptionRepository.updateNextFetchAt(id, nextFetchAt)
} catch (e: Exception) {
_subscriptionsState.value = State.Error("Failed to update next fetch time", e)
}
}
}
fun refreshSubscriptions() {
loadAllSubscriptions()
loadEnabledSubscriptions()
}
}

View File

@@ -0,0 +1,70 @@
package com.rssuper.repository
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.`when`
class FeedRepositoryTest {
private lateinit var feedItemDao: FeedItemDao
private lateinit var feedRepository: FeedRepository
@Before
fun setup() {
feedItemDao = Mockito.mock(FeedItemDao::class.java)
feedRepository = FeedRepositoryImpl(feedItemDao)
}
@Test
fun testGetFeedItemsSuccess() = runTest {
val items = listOf(
FeedItemEntity(
id = "1",
subscriptionId = "sub1",
title = "Test Item",
published = java.util.Date()
)
)
val stateFlow = MutableStateFlow<State<List<FeedItemEntity>>>(State.Success(items))
`when`(feedItemDao.getItemsBySubscription("sub1")).thenReturn(stateFlow)
feedRepository.getFeedItems("sub1").collect { state ->
assert(state is State.Success)
assert((state as State.Success).data == items)
}
}
@Test
fun testInsertFeedItemSuccess() = runTest {
val item = FeedItemEntity(
id = "1",
subscriptionId = "sub1",
title = "Test Item",
published = java.util.Date()
)
`when`(feedItemDao.insertItem(item)).thenReturn(1L)
val result = feedRepository.insertFeedItem(item)
assert(result == 1L)
}
@Test(expected = RuntimeException::class)
fun testInsertFeedItemError() = runTest {
`when`(feedItemDao.insertItem(Mockito.any())).thenThrow(RuntimeException("Database error"))
feedRepository.insertFeedItem(FeedItemEntity(
id = "1",
subscriptionId = "sub1",
title = "Test Item",
published = java.util.Date()
))
}
}

View File

@@ -0,0 +1,108 @@
package com.rssuper.repository
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.SubscriptionEntity
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.`when`
import java.util.Date
class SubscriptionRepositoryTest {
private lateinit var subscriptionDao: SubscriptionDao
private lateinit var subscriptionRepository: SubscriptionRepository
@Before
fun setup() {
subscriptionDao = Mockito.mock(SubscriptionDao::class.java)
subscriptionRepository = SubscriptionRepositoryImpl(subscriptionDao)
}
@Test
fun testGetAllSubscriptionsSuccess() = runTest {
val subscriptions = listOf(
SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
createdAt = Date(),
updatedAt = Date()
)
)
val stateFlow = MutableStateFlow<State<List<SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionDao.getAllSubscriptions()).thenReturn(stateFlow)
subscriptionRepository.getAllSubscriptions().collect { state ->
assert(state is State.Success)
assert((state as State.Success).data == subscriptions)
}
}
@Test
fun testGetEnabledSubscriptionsSuccess() = runTest {
val subscriptions = listOf(
SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
enabled = true,
createdAt = Date(),
updatedAt = Date()
)
)
val stateFlow = MutableStateFlow<State<List<SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionDao.getEnabledSubscriptions()).thenReturn(stateFlow)
subscriptionRepository.getEnabledSubscriptions().collect { state ->
assert(state is State.Success)
assert((state as State.Success).data == subscriptions)
}
}
@Test
fun testInsertSubscriptionSuccess() = runTest {
val subscription = SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
createdAt = Date(),
updatedAt = Date()
)
`when`(subscriptionDao.insertSubscription(subscription)).thenReturn(1L)
val result = subscriptionRepository.insertSubscription(subscription)
assert(result == 1L)
}
@Test
fun testUpdateSubscriptionSuccess() = runTest {
val subscription = SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
enabled = true,
createdAt = Date(),
updatedAt = Date()
)
`when`(subscriptionDao.updateSubscription(subscription)).thenReturn(1)
val result = subscriptionRepository.updateSubscription(subscription)
assert(result == 1)
}
@Test
fun testSetEnabledSuccess() = runTest {
`when`(subscriptionDao.setEnabled("1", true)).thenReturn(1)
val result = subscriptionRepository.setEnabled("1", true)
assert(result == 1)
}
}

View File

@@ -0,0 +1,66 @@
package com.rssuper.state
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class StateTest {
@Test
fun testIdleState() {
val state: State<String> = State.Idle
assertTrue(state is State.Idle)
}
@Test
fun testLoadingState() {
val state: State<String> = State.Loading
assertTrue(state is State.Loading)
}
@Test
fun testSuccessState() {
val data = "test data"
val state: State<String> = State.Success(data)
assertTrue(state is State.Success)
assertEquals(data, (state as State.Success).data)
}
@Test
fun testErrorState() {
val message = "test error"
val state: State<String> = State.Error(message)
assertTrue(state is State.Error)
assertEquals(message, (state as State.Error).message)
assertEquals(null, (state as State.Error).cause)
}
@Test
fun testErrorStateWithCause() {
val message = "test error"
val cause = RuntimeException("cause")
val state: State<String> = State.Error(message, cause)
assertTrue(state is State.Error)
assertEquals(message, (state as State.Error).message)
assertEquals(cause, (state as State.Error).cause)
}
@Test
fun testErrorType() {
assertTrue(ErrorType.NETWORK != ErrorType.DATABASE)
assertTrue(ErrorType.PARSING != ErrorType.AUTH)
}
@Test
fun testErrorDetails() {
val details = ErrorDetails(ErrorType.NETWORK, "Network error", true)
assertEquals(ErrorType.NETWORK, details.type)
assertEquals("Network error", details.message)
assertTrue(details.retryable)
}
}

View File

@@ -0,0 +1,73 @@
package com.rssuper.viewmodel
import com.rssuper.repository.FeedRepository
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.`when`
class FeedViewModelTest {
private lateinit var feedRepository: FeedRepository
private lateinit var viewModel: FeedViewModel
@Before
fun setup() {
feedRepository = Mockito.mock(FeedRepository::class.java)
viewModel = FeedViewModel(feedRepository)
}
@Test
fun testInitialState() = runTest {
var stateEmitted = false
viewModel.feedState.collect { state ->
assert(state is State.Idle)
stateEmitted = true
}
assert(stateEmitted)
}
@Test
fun testLoadFeedItems() = runTest {
val items = listOf(
com.rssuper.database.entities.FeedItemEntity(
id = "1",
subscriptionId = "sub1",
title = "Test Item",
published = java.util.Date()
)
)
val stateFlow = MutableStateFlow<State<List<com.rssuper.database.entities.FeedItemEntity>>>(State.Success(items))
`when`(feedRepository.getFeedItems("sub1")).thenReturn(stateFlow)
viewModel.loadFeedItems("sub1")
var receivedState: State<List<com.rssuper.database.entities.FeedItemEntity>>? = null
viewModel.feedState.collect { state ->
receivedState = state
}
assert(receivedState is State.Success)
assert((receivedState as State.Success).data == items)
}
@Test
fun testMarkAsRead() = runTest {
`when`(feedRepository.markAsRead("1", true)).thenReturn(1)
`when`(feedRepository.getUnreadCount("sub1")).thenReturn(5)
viewModel.markAsRead("1", true)
var unreadCountState: State<Int>? = null
viewModel.unreadCount.collect { state ->
unreadCountState = state
}
assert(unreadCountState is State.Success)
assert((unreadCountState as State.Success).data == 5)
}
}

View File

@@ -0,0 +1,100 @@
package com.rssuper.viewmodel
import com.rssuper.repository.SubscriptionRepository
import com.rssuper.state.State
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.`when`
import java.util.Date
class SubscriptionViewModelTest {
private lateinit var subscriptionRepository: SubscriptionRepository
private lateinit var viewModel: SubscriptionViewModel
@Before
fun setup() {
subscriptionRepository = Mockito.mock(SubscriptionRepository::class.java)
viewModel = SubscriptionViewModel(subscriptionRepository)
}
@Test
fun testInitialState() = runTest {
var stateEmitted = false
viewModel.subscriptionsState.collect { state ->
assert(state is State.Idle)
stateEmitted = true
}
assert(stateEmitted)
}
@Test
fun testLoadAllSubscriptions() = runTest {
val subscriptions = listOf(
com.rssuper.database.entities.SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
createdAt = Date(),
updatedAt = Date()
)
)
val stateFlow = MutableStateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionRepository.getAllSubscriptions()).thenReturn(stateFlow)
viewModel.loadAllSubscriptions()
var receivedState: State<List<com.rssuper.database.entities.SubscriptionEntity>>? = null
viewModel.subscriptionsState.collect { state ->
receivedState = state
}
assert(receivedState is State.Success)
assert((receivedState as State.Success).data == subscriptions)
}
@Test
fun testSetEnabled() = runTest {
val subscriptions = listOf(
com.rssuper.database.entities.SubscriptionEntity(
id = "1",
url = "https://example.com/feed.xml",
title = "Test Feed",
enabled = true,
createdAt = Date(),
updatedAt = Date()
)
)
val stateFlow = MutableStateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionRepository.setEnabled("1", true)).thenReturn(1)
`when`(subscriptionRepository.getEnabledSubscriptions()).thenReturn(stateFlow)
viewModel.setEnabled("1", true)
var receivedState: State<List<com.rssuper.database.entities.SubscriptionEntity>>? = null
viewModel.enabledSubscriptionsState.collect { state ->
receivedState = state
}
assert(receivedState is State.Success)
assert((receivedState as State.Success).data == subscriptions)
}
@Test
fun testSetError() = runTest {
`when`(subscriptionRepository.setError("1", "Test error")).thenReturn(1)
viewModel.setError("1", "Test error")
var stateEmitted = false
viewModel.subscriptionsState.collect { state ->
stateEmitted = true
}
assert(stateEmitted)
}
}