Auto-commit 2026-03-30 16:30
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user