restructure
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.rssuper.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.Date
|
||||
|
||||
class DateConverter {
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? {
|
||||
return value?.let { Date(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? {
|
||||
return date?.time
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.rssuper.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.rssuper.models.FeedItem
|
||||
|
||||
class FeedItemListConverter {
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
private val adapter = moshi.adapter(List::class.java)
|
||||
|
||||
@TypeConverter
|
||||
fun fromFeedItemList(value: List<FeedItem>?): String? {
|
||||
return value?.let { adapter.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toFeedItemList(value: String?): List<FeedItem>? {
|
||||
return value?.let { adapter.fromJson(it) as? List<FeedItem> }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.rssuper.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
|
||||
class StringListConverter {
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>?): String? {
|
||||
return value?.joinToString(",")
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String?): List<String>? {
|
||||
return value?.split(",")?.filter { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
87
android/src/main/java/com/rssuper/database/RssDatabase.kt
Normal file
87
android/src/main/java/com/rssuper/database/RssDatabase.kt
Normal file
@@ -0,0 +1,87 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rssuper.converters.DateConverter
|
||||
import com.rssuper.converters.FeedItemListConverter
|
||||
import com.rssuper.converters.StringListConverter
|
||||
import com.rssuper.database.daos.FeedItemDao
|
||||
import com.rssuper.database.daos.SearchHistoryDao
|
||||
import com.rssuper.database.daos.SubscriptionDao
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
SubscriptionEntity::class,
|
||||
FeedItemEntity::class,
|
||||
SearchHistoryEntity::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class)
|
||||
abstract class RssDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun feedItemDao(): FeedItemDao
|
||||
abstract fun searchHistoryDao(): SearchHistoryDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: RssDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): RssDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RssDatabase::class.java,
|
||||
"rss_database"
|
||||
)
|
||||
.addCallback(DatabaseCallback())
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DatabaseCallback : RoomDatabase.Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
INSTANCE?.let { database ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
createFTSVirtualTable(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen(db: SupportSQLiteDatabase) {
|
||||
super.onOpen(db)
|
||||
createFTSVirtualTable(db)
|
||||
}
|
||||
|
||||
private fun createFTSVirtualTable(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
author,
|
||||
content='feed_items',
|
||||
contentless_delete=true
|
||||
)
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface FeedItemDao {
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC")
|
||||
fun getItemsBySubscription(subscriptionId: String): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE id = :id")
|
||||
suspend fun getItemById(id: String): FeedItemEntity?
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC")
|
||||
fun getItemsBySubscriptions(subscriptionIds: List<String>): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
|
||||
fun getUnreadItems(): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
|
||||
fun getStarredItems(): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
|
||||
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
|
||||
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
|
||||
fun getUnreadCount(subscriptionId: String): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
|
||||
fun getTotalUnreadCount(): Flow<Int>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItem(item: FeedItemEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateItem(item: FeedItemEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteItem(item: FeedItemEntity): Int
|
||||
|
||||
@Query("DELETE FROM feed_items WHERE id = :id")
|
||||
suspend fun deleteItemById(id: String): Int
|
||||
|
||||
@Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId")
|
||||
suspend fun deleteItemsBySubscription(subscriptionId: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 1 WHERE id = :id")
|
||||
suspend fun markAsRead(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 0 WHERE id = :id")
|
||||
suspend fun markAsUnread(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id")
|
||||
suspend fun markAsStarred(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id")
|
||||
suspend fun markAsUnstarred(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId")
|
||||
suspend fun markAllAsRead(subscriptionId: String): Int
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
|
||||
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
|
||||
|
||||
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit")
|
||||
suspend fun searchByFts(query: String, limit: Int = 20): List<FeedItemEntity>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SearchHistoryDao {
|
||||
@Query("SELECT * FROM search_history ORDER BY timestamp DESC")
|
||||
fun getAllSearchHistory(): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM search_history WHERE id = :id")
|
||||
suspend fun getSearchHistoryById(id: String): SearchHistoryEntity?
|
||||
|
||||
@Query("SELECT * FROM search_history WHERE query LIKE '%' || :query || '%' ORDER BY timestamp DESC")
|
||||
fun searchHistory(query: String): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentSearches(limit: Int): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM search_history")
|
||||
fun getSearchHistoryCount(): Flow<Int>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertSearchHistory(search: SearchHistoryEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertSearchHistories(searches: List<SearchHistoryEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateSearchHistory(search: SearchHistoryEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteSearchHistory(search: SearchHistoryEntity): Int
|
||||
|
||||
@Query("DELETE FROM search_history WHERE id = :id")
|
||||
suspend fun deleteSearchHistoryById(id: String): Int
|
||||
|
||||
@Query("DELETE FROM search_history")
|
||||
suspend fun deleteAllSearchHistory(): Int
|
||||
|
||||
@Query("DELETE FROM search_history WHERE timestamp < :timestamp")
|
||||
suspend fun deleteSearchHistoryOlderThan(timestamp: Long): Int
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface SubscriptionDao {
|
||||
@Query("SELECT * FROM subscriptions ORDER BY title ASC")
|
||||
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE id = :id")
|
||||
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE url = :url")
|
||||
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC")
|
||||
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
|
||||
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteSubscription(subscription: SubscriptionEntity): Int
|
||||
|
||||
@Query("DELETE FROM subscriptions WHERE id = :id")
|
||||
suspend fun deleteSubscriptionById(id: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||
fun getSubscriptionCount(): Flow<Int>
|
||||
|
||||
@Query("UPDATE subscriptions SET error = :error WHERE id = :id")
|
||||
suspend fun updateError(id: String, error: String?)
|
||||
|
||||
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
|
||||
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date)
|
||||
|
||||
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
|
||||
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
|
||||
|
||||
@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,57 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "feed_items",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["subscriptionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["subscriptionId"]),
|
||||
Index(value = ["published"])
|
||||
]
|
||||
)
|
||||
data class FeedItemEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val subscriptionId: String,
|
||||
|
||||
val title: String,
|
||||
|
||||
val link: String? = null,
|
||||
|
||||
val description: String? = null,
|
||||
|
||||
val content: String? = null,
|
||||
|
||||
val author: String? = null,
|
||||
|
||||
val published: Date? = null,
|
||||
|
||||
val updated: Date? = null,
|
||||
|
||||
val categories: String? = null,
|
||||
|
||||
val enclosureUrl: String? = null,
|
||||
|
||||
val enclosureType: String? = null,
|
||||
|
||||
val enclosureLength: Long? = null,
|
||||
|
||||
val guid: String? = null,
|
||||
|
||||
val isRead: Boolean = false,
|
||||
|
||||
val isStarred: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "search_history",
|
||||
indices = [Index(value = ["timestamp"])]
|
||||
)
|
||||
data class SearchHistoryEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val query: String,
|
||||
|
||||
val timestamp: Date
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.rssuper.models.HttpAuth
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "subscriptions",
|
||||
indices = [Index(value = ["url"], unique = true)]
|
||||
)
|
||||
data class SubscriptionEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val url: String,
|
||||
|
||||
val title: String,
|
||||
|
||||
val category: String? = null,
|
||||
|
||||
val enabled: Boolean = true,
|
||||
|
||||
val fetchInterval: Long = 3600000,
|
||||
|
||||
val createdAt: Date,
|
||||
|
||||
val updatedAt: Date,
|
||||
|
||||
val lastFetchedAt: Date? = null,
|
||||
|
||||
val nextFetchAt: Date? = null,
|
||||
|
||||
val error: String? = null,
|
||||
|
||||
val httpAuthUsername: String? = null,
|
||||
|
||||
val httpAuthPassword: String? = null
|
||||
) {
|
||||
fun toHttpAuth(): HttpAuth? {
|
||||
return if (httpAuthUsername != null && httpAuthPassword != null) {
|
||||
HttpAuth(httpAuthUsername, httpAuthPassword)
|
||||
} else null
|
||||
}
|
||||
|
||||
fun fromHttpAuth(auth: HttpAuth?): SubscriptionEntity {
|
||||
return copy(
|
||||
httpAuthUsername = auth?.username,
|
||||
httpAuthPassword = auth?.password
|
||||
)
|
||||
}
|
||||
}
|
||||
9
android/src/main/java/com/rssuper/model/Error.kt
Normal file
9
android/src/main/java/com/rssuper/model/Error.kt
Normal 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
|
||||
}
|
||||
8
android/src/main/java/com/rssuper/model/State.kt
Normal file
8
android/src/main/java/com/rssuper/model/State.kt
Normal 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>
|
||||
}
|
||||
60
android/src/main/java/com/rssuper/models/Feed.kt
Normal file
60
android/src/main/java/com/rssuper/models/Feed.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.rssuper.converters.DateConverter
|
||||
import com.rssuper.converters.FeedItemListConverter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@TypeConverters(DateConverter::class, FeedItemListConverter::class)
|
||||
@Entity(tableName = "feeds")
|
||||
data class Feed(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
@Json(name = "title")
|
||||
val title: String,
|
||||
|
||||
@Json(name = "link")
|
||||
val link: String? = null,
|
||||
|
||||
@Json(name = "description")
|
||||
val description: String? = null,
|
||||
|
||||
@Json(name = "subtitle")
|
||||
val subtitle: String? = null,
|
||||
|
||||
@Json(name = "language")
|
||||
val language: String? = null,
|
||||
|
||||
@Json(name = "lastBuildDate")
|
||||
val lastBuildDate: Date? = null,
|
||||
|
||||
@Json(name = "updated")
|
||||
val updated: Date? = null,
|
||||
|
||||
@Json(name = "generator")
|
||||
val generator: String? = null,
|
||||
|
||||
@Json(name = "ttl")
|
||||
val ttl: Int? = null,
|
||||
|
||||
@Json(name = "items")
|
||||
val items: List<FeedItem> = emptyList(),
|
||||
|
||||
@Json(name = "rawUrl")
|
||||
val rawUrl: String,
|
||||
|
||||
@Json(name = "lastFetchedAt")
|
||||
val lastFetchedAt: Date? = null,
|
||||
|
||||
@Json(name = "nextFetchAt")
|
||||
val nextFetchAt: Date? = null
|
||||
) : Parcelable
|
||||
67
android/src/main/java/com/rssuper/models/FeedItem.kt
Normal file
67
android/src/main/java/com/rssuper/models/FeedItem.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.rssuper.converters.DateConverter
|
||||
import com.rssuper.converters.StringListConverter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@TypeConverters(DateConverter::class, StringListConverter::class)
|
||||
@Entity(tableName = "feed_items")
|
||||
data class FeedItem(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
@Json(name = "title")
|
||||
val title: String,
|
||||
|
||||
@Json(name = "link")
|
||||
val link: String? = null,
|
||||
|
||||
@Json(name = "description")
|
||||
val description: String? = null,
|
||||
|
||||
@Json(name = "content")
|
||||
val content: String? = null,
|
||||
|
||||
@Json(name = "author")
|
||||
val author: String? = null,
|
||||
|
||||
@Json(name = "published")
|
||||
val published: Date? = null,
|
||||
|
||||
@Json(name = "updated")
|
||||
val updated: Date? = null,
|
||||
|
||||
@Json(name = "categories")
|
||||
val categories: List<String>? = null,
|
||||
|
||||
@Json(name = "enclosure")
|
||||
val enclosure: Enclosure? = null,
|
||||
|
||||
@Json(name = "guid")
|
||||
val guid: String? = null,
|
||||
|
||||
@Json(name = "subscriptionTitle")
|
||||
val subscriptionTitle: String? = null
|
||||
) : Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Enclosure(
|
||||
@Json(name = "url")
|
||||
val url: String,
|
||||
|
||||
@Json(name = "type")
|
||||
val type: String,
|
||||
|
||||
@Json(name = "length")
|
||||
val length: Long? = null
|
||||
) : Parcelable
|
||||
63
android/src/main/java/com/rssuper/models/FeedSubscription.kt
Normal file
63
android/src/main/java/com/rssuper/models/FeedSubscription.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.rssuper.converters.DateConverter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@TypeConverters(DateConverter::class)
|
||||
@Entity(tableName = "feed_subscriptions")
|
||||
data class FeedSubscription(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
@Json(name = "url")
|
||||
val url: String,
|
||||
|
||||
@Json(name = "title")
|
||||
val title: String,
|
||||
|
||||
@Json(name = "category")
|
||||
val category: String? = null,
|
||||
|
||||
@Json(name = "enabled")
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@Json(name = "fetchInterval")
|
||||
val fetchInterval: Long,
|
||||
|
||||
@Json(name = "createdAt")
|
||||
val createdAt: Date,
|
||||
|
||||
@Json(name = "updatedAt")
|
||||
val updatedAt: Date,
|
||||
|
||||
@Json(name = "lastFetchedAt")
|
||||
val lastFetchedAt: Date? = null,
|
||||
|
||||
@Json(name = "nextFetchAt")
|
||||
val nextFetchAt: Date? = null,
|
||||
|
||||
@Json(name = "error")
|
||||
val error: String? = null,
|
||||
|
||||
@Json(name = "httpAuth")
|
||||
val httpAuth: HttpAuth? = null
|
||||
) : Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class HttpAuth(
|
||||
@Json(name = "username")
|
||||
val username: String,
|
||||
|
||||
@Json(name = "password")
|
||||
val password: String
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@Entity(tableName = "notification_preferences")
|
||||
data class NotificationPreferences(
|
||||
@PrimaryKey
|
||||
val id: String = "default",
|
||||
|
||||
@Json(name = "newArticles")
|
||||
val newArticles: Boolean = true,
|
||||
|
||||
@Json(name = "episodeReleases")
|
||||
val episodeReleases: Boolean = true,
|
||||
|
||||
@Json(name = "customAlerts")
|
||||
val customAlerts: Boolean = false,
|
||||
|
||||
@Json(name = "badgeCount")
|
||||
val badgeCount: Boolean = true,
|
||||
|
||||
@Json(name = "sound")
|
||||
val sound: Boolean = true,
|
||||
|
||||
@Json(name = "vibration")
|
||||
val vibration: Boolean = true
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@Entity(tableName = "reading_preferences")
|
||||
data class ReadingPreferences(
|
||||
@PrimaryKey
|
||||
val id: String = "default",
|
||||
|
||||
@Json(name = "fontSize")
|
||||
val fontSize: @RawValue FontSize = FontSize.MEDIUM,
|
||||
|
||||
@Json(name = "lineHeight")
|
||||
val lineHeight: @RawValue LineHeight = LineHeight.NORMAL,
|
||||
|
||||
@Json(name = "showTableOfContents")
|
||||
val showTableOfContents: Boolean = false,
|
||||
|
||||
@Json(name = "showReadingTime")
|
||||
val showReadingTime: Boolean = true,
|
||||
|
||||
@Json(name = "showAuthor")
|
||||
val showAuthor: Boolean = true,
|
||||
|
||||
@Json(name = "showDate")
|
||||
val showDate: Boolean = true
|
||||
) : Parcelable
|
||||
|
||||
sealed class FontSize(val value: String) {
|
||||
@Json(name = "small")
|
||||
data object SMALL : FontSize("small")
|
||||
|
||||
@Json(name = "medium")
|
||||
data object MEDIUM : FontSize("medium")
|
||||
|
||||
@Json(name = "large")
|
||||
data object LARGE : FontSize("large")
|
||||
|
||||
@Json(name = "xlarge")
|
||||
data object XLARGE : FontSize("xlarge")
|
||||
}
|
||||
|
||||
sealed class LineHeight(val value: String) {
|
||||
@Json(name = "normal")
|
||||
data object NORMAL : LineHeight("normal")
|
||||
|
||||
@Json(name = "relaxed")
|
||||
data object RELAXED : LineHeight("relaxed")
|
||||
|
||||
@Json(name = "loose")
|
||||
data object LOOSE : LineHeight("loose")
|
||||
}
|
||||
74
android/src/main/java/com/rssuper/models/SearchFilters.kt
Normal file
74
android/src/main/java/com/rssuper/models/SearchFilters.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.rssuper.converters.DateConverter
|
||||
import com.rssuper.converters.StringListConverter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@TypeConverters(DateConverter::class, StringListConverter::class)
|
||||
@Entity(tableName = "search_filters")
|
||||
data class SearchFilters(
|
||||
@PrimaryKey
|
||||
val id: String = "default",
|
||||
|
||||
@Json(name = "dateFrom")
|
||||
val dateFrom: Date? = null,
|
||||
|
||||
@Json(name = "dateTo")
|
||||
val dateTo: Date? = null,
|
||||
|
||||
@Json(name = "feedIds")
|
||||
val feedIds: List<String>? = null,
|
||||
|
||||
@Json(name = "authors")
|
||||
val authors: List<String>? = null,
|
||||
|
||||
@Json(name = "contentType")
|
||||
val contentType: @RawValue ContentType? = null,
|
||||
|
||||
@Json(name = "sortOption")
|
||||
val sortOption: @RawValue SearchSortOption = SearchSortOption.RELEVANCE
|
||||
) : Parcelable
|
||||
|
||||
sealed class ContentType(val value: String) {
|
||||
@Json(name = "article")
|
||||
data object ARTICLE : ContentType("article")
|
||||
|
||||
@Json(name = "audio")
|
||||
data object AUDIO : ContentType("audio")
|
||||
|
||||
@Json(name = "video")
|
||||
data object VIDEO : ContentType("video")
|
||||
}
|
||||
|
||||
sealed class SearchSortOption(val value: String) {
|
||||
@Json(name = "relevance")
|
||||
data object RELEVANCE : SearchSortOption("relevance")
|
||||
|
||||
@Json(name = "date_desc")
|
||||
data object DATE_DESC : SearchSortOption("date_desc")
|
||||
|
||||
@Json(name = "date_asc")
|
||||
data object DATE_ASC : SearchSortOption("date_asc")
|
||||
|
||||
@Json(name = "title_asc")
|
||||
data object TITLE_ASC : SearchSortOption("title_asc")
|
||||
|
||||
@Json(name = "title_desc")
|
||||
data object TITLE_DESC : SearchSortOption("title_desc")
|
||||
|
||||
@Json(name = "feed_asc")
|
||||
data object FEED_ASC : SearchSortOption("feed_asc")
|
||||
|
||||
@Json(name = "feed_desc")
|
||||
data object FEED_DESC : SearchSortOption("feed_desc")
|
||||
}
|
||||
49
android/src/main/java/com/rssuper/models/SearchResult.kt
Normal file
49
android/src/main/java/com/rssuper/models/SearchResult.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.rssuper.converters.DateConverter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
@TypeConverters(DateConverter::class)
|
||||
@Entity(tableName = "search_results")
|
||||
data class SearchResult(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
@Json(name = "type")
|
||||
val type: SearchResultType,
|
||||
|
||||
@Json(name = "title")
|
||||
val title: String,
|
||||
|
||||
@Json(name = "snippet")
|
||||
val snippet: String? = null,
|
||||
|
||||
@Json(name = "link")
|
||||
val link: String? = null,
|
||||
|
||||
@Json(name = "feedTitle")
|
||||
val feedTitle: String? = null,
|
||||
|
||||
@Json(name = "published")
|
||||
val published: Date? = null,
|
||||
|
||||
@Json(name = "score")
|
||||
val score: Double? = null
|
||||
) : Parcelable
|
||||
|
||||
enum class SearchResultType {
|
||||
@Json(name = "article")
|
||||
ARTICLE,
|
||||
|
||||
@Json(name = "feed")
|
||||
FEED
|
||||
}
|
||||
240
android/src/main/java/com/rssuper/parsing/AtomParser.kt
Normal file
240
android/src/main/java/com/rssuper/parsing/AtomParser.kt
Normal file
@@ -0,0 +1,240 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Enclosure
|
||||
import com.rssuper.models.Feed
|
||||
import com.rssuper.models.FeedItem
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.StringReader
|
||||
|
||||
object AtomParser {
|
||||
|
||||
private val ATOM_NS = "http://www.w3.org/2005/Atom"
|
||||
private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
private val MEDIA_NS = "http://search.yahoo.com/mrss/"
|
||||
|
||||
fun parse(xml: String, feedUrl: String): Feed {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
|
||||
var title: String? = null
|
||||
var link: String? = null
|
||||
var subtitle: String? = null
|
||||
var updated: java.util.Date? = null
|
||||
var generator: String? = null
|
||||
val items = mutableListOf<FeedItem>()
|
||||
|
||||
var currentItem: MutableMap<String, Any?>? = null
|
||||
var currentTag: String? = null
|
||||
var inContent = false
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
val tagName = parser.name
|
||||
val namespace = parser.namespace
|
||||
|
||||
when {
|
||||
tagName == "feed" -> {}
|
||||
tagName == "entry" -> {
|
||||
currentItem = mutableMapOf()
|
||||
}
|
||||
tagName == "title" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "link" -> {
|
||||
val href = parser.getAttributeValue(null, "href")
|
||||
val rel = parser.getAttributeValue(null, "rel")
|
||||
if (href != null) {
|
||||
if (currentItem != null) {
|
||||
if (rel == "alternate" || rel == null) {
|
||||
currentItem["link"] = href
|
||||
} else if (rel == "enclosure") {
|
||||
val type = parser.getAttributeValue(null, "type") ?: "application/octet-stream"
|
||||
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
|
||||
currentItem["enclosure"] = Enclosure(href, type, length)
|
||||
}
|
||||
} else {
|
||||
if (rel == "alternate" || rel == null) {
|
||||
link = href
|
||||
}
|
||||
}
|
||||
}
|
||||
currentTag = null
|
||||
inContent = false
|
||||
}
|
||||
tagName == "subtitle" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "summary" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "content" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "updated" || tagName == "published" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "name" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "uri" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "id" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "category" -> {
|
||||
val term = parser.getAttributeValue(null, "term")
|
||||
if (term != null && currentItem != null) {
|
||||
val cats = currentItem["categories"] as? MutableList<String> ?: mutableListOf()
|
||||
cats.add(term)
|
||||
currentItem["categories"] = cats
|
||||
}
|
||||
currentTag = null
|
||||
inContent = false
|
||||
}
|
||||
tagName == "generator" -> {
|
||||
currentTag = tagName
|
||||
inContent = true
|
||||
}
|
||||
tagName == "summary" && namespace == ITUNES_NS -> {
|
||||
if (currentItem != null) {
|
||||
currentItem["itunesSummary"] = readElementText(parser)
|
||||
}
|
||||
}
|
||||
tagName == "image" && namespace == ITUNES_NS -> {
|
||||
val href = parser.getAttributeValue(null, "href")
|
||||
if (href != null && currentItem != null) {
|
||||
currentItem["image"] = href
|
||||
}
|
||||
}
|
||||
tagName == "duration" && namespace == ITUNES_NS -> {
|
||||
currentItem?.put("duration", readElementText(parser))
|
||||
}
|
||||
tagName == "thumbnail" && namespace == MEDIA_NS -> {
|
||||
val url = parser.getAttributeValue(null, "url")
|
||||
if (url != null && currentItem != null) {
|
||||
currentItem["mediaThumbnail"] = url
|
||||
}
|
||||
}
|
||||
tagName == "enclosure" && namespace == MEDIA_NS -> {
|
||||
val url = parser.getAttributeValue(null, "url")
|
||||
val type = parser.getAttributeValue(null, "type")
|
||||
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
|
||||
if (url != null && type != null && currentItem != null) {
|
||||
currentItem["enclosure"] = Enclosure(url, type, length)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text?.xmlTrimmed() ?: ""
|
||||
if (text.isNotEmpty() && inContent) {
|
||||
if (currentItem != null) {
|
||||
when (currentTag) {
|
||||
"title" -> currentItem["title"] = text
|
||||
"summary" -> currentItem["summary"] = text
|
||||
"content" -> currentItem["content"] = text
|
||||
"name" -> currentItem["author"] = text
|
||||
"id" -> currentItem["guid"] = text
|
||||
"updated", "published" -> currentItem[currentTag] = text
|
||||
}
|
||||
} else {
|
||||
when (currentTag) {
|
||||
"title" -> title = text
|
||||
"subtitle" -> subtitle = text
|
||||
"id" -> if (title == null) title = text
|
||||
"updated" -> updated = XmlDateParser.parse(text)
|
||||
"generator" -> generator = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XmlPullParser.END_TAG -> {
|
||||
val tagName = parser.name
|
||||
if (tagName == "entry" && currentItem != null) {
|
||||
items.add(buildFeedItem(currentItem))
|
||||
currentItem = null
|
||||
}
|
||||
if (tagName == currentTag) {
|
||||
currentTag = null
|
||||
inContent = false
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return Feed(
|
||||
id = generateUuid(),
|
||||
title = title ?: "Untitled Feed",
|
||||
link = link,
|
||||
subtitle = subtitle,
|
||||
description = subtitle,
|
||||
updated = updated,
|
||||
generator = generator,
|
||||
items = items,
|
||||
rawUrl = feedUrl,
|
||||
lastFetchedAt = java.util.Date()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readElementText(parser: XmlPullParser): String {
|
||||
var text = ""
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_TAG) {
|
||||
if (eventType == XmlPullParser.TEXT) {
|
||||
text = parser.text.xmlDecoded()
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
return text.xmlTrimmed()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun buildFeedItem(item: Map<String, Any?>): FeedItem {
|
||||
val title = item["title"] as? String ?: "Untitled"
|
||||
val link = item["link"] as? String
|
||||
val summary = item["summary"] as? String
|
||||
val content = item["content"] as? String ?: summary
|
||||
val itunesSummary = item["itunesSummary"] as? String
|
||||
val author = item["author"] as? String
|
||||
val guid = item["guid"] as? String ?: link ?: generateUuid()
|
||||
val categories = item["categories"] as? List<String>
|
||||
val enclosure = item["enclosure"] as? Enclosure
|
||||
|
||||
val updatedStr = item["updated"] as? String
|
||||
val publishedStr = item["published"] as? String
|
||||
val published = XmlDateParser.parse(publishedStr ?: updatedStr)
|
||||
val updated = XmlDateParser.parse(updatedStr)
|
||||
|
||||
return FeedItem(
|
||||
id = generateUuid(),
|
||||
title = title,
|
||||
link = link,
|
||||
description = summary ?: itunesSummary,
|
||||
content = content,
|
||||
author = author,
|
||||
published = published,
|
||||
updated = updated,
|
||||
categories = categories,
|
||||
enclosure = enclosure,
|
||||
guid = guid
|
||||
)
|
||||
}
|
||||
}
|
||||
67
android/src/main/java/com/rssuper/parsing/FeedParser.kt
Normal file
67
android/src/main/java/com/rssuper/parsing/FeedParser.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Feed
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.StringReader
|
||||
import java.util.Date
|
||||
|
||||
object FeedParser {
|
||||
|
||||
fun parse(xml: String, feedUrl: String): ParseResult {
|
||||
val feedType = detectFeedType(xml)
|
||||
|
||||
return when (feedType) {
|
||||
FeedType.RSS -> {
|
||||
val feed = RSSParser.parse(xml, feedUrl)
|
||||
ParseResult(FeedType.RSS, feed)
|
||||
}
|
||||
FeedType.Atom -> {
|
||||
val feed = AtomParser.parse(xml, feedUrl)
|
||||
ParseResult(FeedType.Atom, feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAsync(xml: String, feedUrl: String, callback: (Result<ParseResult>) -> Unit) {
|
||||
try {
|
||||
val result = parse(xml, feedUrl)
|
||||
callback(Result.success(result))
|
||||
} catch (e: Exception) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectFeedType(xml: String): FeedType {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
val tagName = parser.name
|
||||
return when {
|
||||
tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
|
||||
tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
|
||||
tagName.equals("RDF", ignoreCase = true) -> FeedType.RSS
|
||||
else -> {
|
||||
val namespace = parser.namespace
|
||||
if (namespace != null && namespace.isNotEmpty()) {
|
||||
when {
|
||||
tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
|
||||
tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
|
||||
else -> throw FeedParsingError.UnsupportedFeedType
|
||||
}
|
||||
} else {
|
||||
throw FeedParsingError.UnsupportedFeedType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
throw FeedParsingError.UnsupportedFeedType
|
||||
}
|
||||
}
|
||||
16
android/src/main/java/com/rssuper/parsing/FeedType.kt
Normal file
16
android/src/main/java/com/rssuper/parsing/FeedType.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
sealed class FeedType(val value: String) {
|
||||
data object RSS : FeedType("rss")
|
||||
data object Atom : FeedType("atom")
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): FeedType {
|
||||
return when (value.lowercase()) {
|
||||
"rss" -> RSS
|
||||
"atom" -> Atom
|
||||
else -> throw IllegalArgumentException("Unknown feed type: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
android/src/main/java/com/rssuper/parsing/ParseResult.kt
Normal file
13
android/src/main/java/com/rssuper/parsing/ParseResult.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Feed
|
||||
|
||||
data class ParseResult(
|
||||
val feedType: FeedType,
|
||||
val feed: Feed
|
||||
)
|
||||
|
||||
sealed class FeedParsingError : Exception() {
|
||||
data object UnsupportedFeedType : FeedParsingError()
|
||||
data object MalformedXml : FeedParsingError()
|
||||
}
|
||||
188
android/src/main/java/com/rssuper/parsing/RSSParser.kt
Normal file
188
android/src/main/java/com/rssuper/parsing/RSSParser.kt
Normal file
@@ -0,0 +1,188 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Enclosure
|
||||
import com.rssuper.models.Feed
|
||||
import com.rssuper.models.FeedItem
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.StringReader
|
||||
import java.util.Date
|
||||
|
||||
object RSSParser {
|
||||
|
||||
private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
private val CONTENT_NS = "http://purl.org/rss/1.0/modules/content/"
|
||||
|
||||
fun parse(xml: String, feedUrl: String): Feed {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
|
||||
var title: String? = null
|
||||
var link: String? = null
|
||||
var description: String? = null
|
||||
var language: String? = null
|
||||
var lastBuildDate: Date? = null
|
||||
var generator: String? = null
|
||||
var ttl: Int? = null
|
||||
val items = mutableListOf<FeedItem>()
|
||||
|
||||
var currentItem: MutableMap<String, Any?>? = null
|
||||
var currentTag: String? = null
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
val tagName = parser.name
|
||||
val namespace = parser.namespace
|
||||
|
||||
when {
|
||||
tagName == "channel" -> {}
|
||||
tagName == "item" -> {
|
||||
currentItem = mutableMapOf()
|
||||
}
|
||||
tagName == "title" || tagName == "description" ||
|
||||
tagName == "link" || tagName == "author" ||
|
||||
tagName == "guid" || tagName == "pubDate" ||
|
||||
tagName == "category" || tagName == "enclosure" -> {
|
||||
currentTag = tagName
|
||||
}
|
||||
tagName == "language" -> currentTag = tagName
|
||||
tagName == "lastBuildDate" -> currentTag = tagName
|
||||
tagName == "generator" -> currentTag = tagName
|
||||
tagName == "ttl" -> currentTag = tagName
|
||||
|
||||
tagName == "subtitle" && namespace == ITUNES_NS -> {
|
||||
if (currentItem == null) {
|
||||
description = readElementText(parser)
|
||||
}
|
||||
}
|
||||
tagName == "summary" && namespace == ITUNES_NS -> {
|
||||
currentItem?.put("description", readElementText(parser))
|
||||
}
|
||||
tagName == "duration" && namespace == ITUNES_NS -> {
|
||||
currentItem?.put("duration", readElementText(parser))
|
||||
}
|
||||
tagName == "image" && namespace == ITUNES_NS -> {
|
||||
val href = parser.getAttributeValue(null, "href")
|
||||
if (href != null && currentItem != null) {
|
||||
currentItem.put("image", href)
|
||||
}
|
||||
}
|
||||
tagName == "encoded" && namespace == CONTENT_NS -> {
|
||||
currentItem?.put("content", readElementText(parser))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (tagName == "enclosure" && currentItem != null) {
|
||||
val url = parser.getAttributeValue(null, "url")
|
||||
val type = parser.getAttributeValue(null, "type")
|
||||
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
|
||||
if (url != null && type != null) {
|
||||
currentItem["enclosure"] = Enclosure(url, type, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text?.xmlTrimmed() ?: ""
|
||||
if (text.isNotEmpty()) {
|
||||
if (currentItem != null) {
|
||||
when (currentTag) {
|
||||
"title" -> currentItem["title"] = text
|
||||
"description" -> currentItem["description"] = text
|
||||
"link" -> currentItem["link"] = text
|
||||
"author" -> currentItem["author"] = text
|
||||
"guid" -> currentItem["guid"] = text
|
||||
"pubDate" -> currentItem["pubDate"] = text
|
||||
"category" -> {
|
||||
val cats = currentItem["categories"] as? MutableList<String> ?: mutableListOf()
|
||||
cats.add(text)
|
||||
currentItem["categories"] = cats
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (currentTag) {
|
||||
"title" -> title = text
|
||||
"link" -> link = text
|
||||
"description" -> description = text
|
||||
"language" -> language = text
|
||||
"lastBuildDate" -> lastBuildDate = XmlDateParser.parse(text)
|
||||
"generator" -> generator = text
|
||||
"ttl" -> ttl = text.toIntOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XmlPullParser.END_TAG -> {
|
||||
val tagName = parser.name
|
||||
if (tagName == "item" && currentItem != null) {
|
||||
items.add(buildFeedItem(currentItem))
|
||||
currentItem = null
|
||||
}
|
||||
currentTag = null
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return Feed(
|
||||
id = generateUuid(),
|
||||
title = title ?: "Untitled Feed",
|
||||
link = link,
|
||||
description = description,
|
||||
language = language,
|
||||
lastBuildDate = lastBuildDate,
|
||||
generator = generator,
|
||||
ttl = ttl,
|
||||
items = items,
|
||||
rawUrl = feedUrl,
|
||||
lastFetchedAt = Date()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readElementText(parser: XmlPullParser): String {
|
||||
var text = ""
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_TAG) {
|
||||
if (eventType == XmlPullParser.TEXT) {
|
||||
text = parser.text.xmlDecoded()
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
return text.xmlTrimmed()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun buildFeedItem(item: Map<String, Any?>): FeedItem {
|
||||
val title = item["title"] as? String ?: "Untitled"
|
||||
val link = item["link"] as? String
|
||||
val description = item["description"] as? String
|
||||
val content = item["content"] as? String ?: description
|
||||
val author = item["author"] as? String
|
||||
val guid = item["guid"] as? String ?: link ?: generateUuid()
|
||||
val categories = item["categories"] as? List<String>
|
||||
val enclosure = item["enclosure"] as? Enclosure
|
||||
|
||||
val pubDateStr = item["pubDate"] as? String
|
||||
val published = XmlDateParser.parse(pubDateStr)
|
||||
|
||||
return FeedItem(
|
||||
id = generateUuid(),
|
||||
title = title,
|
||||
link = link,
|
||||
description = description,
|
||||
content = content,
|
||||
author = author,
|
||||
published = published,
|
||||
updated = published,
|
||||
categories = categories,
|
||||
enclosure = enclosure,
|
||||
guid = guid
|
||||
)
|
||||
}
|
||||
}
|
||||
154
android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt
Normal file
154
android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object XmlDateParser {
|
||||
private val iso8601WithFractional: SimpleDateFormat by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
private val iso8601: SimpleDateFormat by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFormats: List<SimpleDateFormat> by lazy {
|
||||
listOf(
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US),
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.US),
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US),
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US),
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
).map {
|
||||
SimpleDateFormat(it.toPattern(), Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(value: String?): java.util.Date? {
|
||||
val trimmed = value?.xmlTrimmed() ?: return null
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
iso8601WithFractional.parse(trimmed)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
iso8601.parse(trimmed)
|
||||
} catch (e: Exception) {
|
||||
for (format in dateFormats) {
|
||||
try {
|
||||
return format.parse(trimmed)
|
||||
} catch (e: Exception) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.xmlTrimmed(): String = this.trim { it <= ' ' }
|
||||
|
||||
fun String.xmlNilIfEmpty(): String? {
|
||||
val trimmed = this.xmlTrimmed()
|
||||
return if (trimmed.isEmpty()) null else trimmed
|
||||
}
|
||||
|
||||
fun String.xmlDecoded(): String {
|
||||
return this
|
||||
.replace(Regex("<!\\[CDATA\\[", RegexOption.IGNORE_CASE), "")
|
||||
.replace(Regex("\\]\\]>", RegexOption.IGNORE_CASE), "")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
fun xmlInt64(value: String?): Long? {
|
||||
val trimmed = value?.xmlTrimmed() ?: return null
|
||||
if (trimmed.isEmpty()) return null
|
||||
return trimmed.toLongOrNull()
|
||||
}
|
||||
|
||||
fun xmlInt(value: String?): Int? {
|
||||
val trimmed = value?.xmlTrimmed() ?: return null
|
||||
if (trimmed.isEmpty()) return null
|
||||
return trimmed.toIntOrNull()
|
||||
}
|
||||
|
||||
fun xmlFirstTagValue(tag: String, inXml: String): String? {
|
||||
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = pattern.matcher(inXml)
|
||||
return if (matcher.find()) {
|
||||
matcher.group(1)?.xmlDecoded()?.xmlTrimmed()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun xmlAllTagValues(tag: String, inXml: String): List<String> {
|
||||
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = pattern.matcher(inXml)
|
||||
val results = mutableListOf<String>()
|
||||
while (matcher.find()) {
|
||||
matcher.group(1)?.xmlDecoded()?.xmlTrimmed()?.let { value ->
|
||||
if (value.isNotEmpty()) {
|
||||
results.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
fun xmlFirstBlock(tag: String, inXml: String): String? {
|
||||
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = pattern.matcher(inXml)
|
||||
return if (matcher.find()) matcher.group(1) else null
|
||||
}
|
||||
|
||||
fun xmlAllBlocks(tag: String, inXml: String): List<String> {
|
||||
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = pattern.matcher(inXml)
|
||||
val results = mutableListOf<String>()
|
||||
while (matcher.find()) {
|
||||
matcher.group(1)?.let { results.add(it) }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
fun xmlAllTagAttributes(tag: String, inXml: String): List<Map<String, String>> {
|
||||
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b([^>]*)/?>", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = pattern.matcher(inXml)
|
||||
val results = mutableListOf<Map<String, String>>()
|
||||
while (matcher.find()) {
|
||||
matcher.group(1)?.let { results.add(parseXmlAttributes(it)) }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun parseXmlAttributes(raw: String): Map<String, String> {
|
||||
val pattern = Pattern.compile("(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"")
|
||||
val matcher = pattern.matcher(raw)
|
||||
val result = mutableMapOf<String, String>()
|
||||
while (matcher.find()) {
|
||||
val key = matcher.group(1)?.lowercase() ?: continue
|
||||
val value = matcher.group(2)?.xmlDecoded()?.xmlTrimmed() ?: continue
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun generateUuid(): String = UUID.randomUUID().toString()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
102
android/src/main/java/com/rssuper/repository/FeedRepository.kt
Normal file
102
android/src/main/java/com/rssuper/repository/FeedRepository.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
32
android/src/main/java/com/rssuper/repository/Repositories.kt
Normal file
32
android/src/main/java/com/rssuper/repository/Repositories.kt
Normal 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
|
||||
}
|
||||
210
android/src/main/java/com/rssuper/repository/RepositoriesImpl.kt
Normal file
210
android/src/main/java/com/rssuper/repository/RepositoriesImpl.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
18
android/src/main/java/com/rssuper/search/SearchQuery.kt
Normal file
18
android/src/main/java/com/rssuper/search/SearchQuery.kt
Normal 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}"
|
||||
}
|
||||
16
android/src/main/java/com/rssuper/search/SearchResult.kt
Normal file
16
android/src/main/java/com/rssuper/search/SearchResult.kt
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
81
android/src/main/java/com/rssuper/search/SearchService.kt
Normal file
81
android/src/main/java/com/rssuper/search/SearchService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
174
android/src/main/java/com/rssuper/services/FeedFetcher.kt
Normal file
174
android/src/main/java/com/rssuper/services/FeedFetcher.kt
Normal file
@@ -0,0 +1,174 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import com.rssuper.parsing.FeedParser
|
||||
import com.rssuper.parsing.ParseResult
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FeedFetcher(
|
||||
private val timeoutMs: Long = 15000,
|
||||
private val maxRetries: Int = 3,
|
||||
private val baseRetryDelayMs: Long = 1000
|
||||
) {
|
||||
private val client: OkHttpClient
|
||||
|
||||
init {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
builder.eventListenerFactory { call -> TimeoutEventListener(call) }
|
||||
|
||||
client = builder.build()
|
||||
}
|
||||
|
||||
fun fetch(
|
||||
url: String,
|
||||
httpAuth: HTTPAuthCredentials? = null,
|
||||
ifNoneMatch: String? = null,
|
||||
ifModifiedSince: String? = null
|
||||
): NetworkResult<FetchResult> {
|
||||
var lastError: Throwable? = null
|
||||
|
||||
for (attempt in 1..maxRetries) {
|
||||
val result = fetchSingleAttempt(url, httpAuth, ifNoneMatch, ifModifiedSince)
|
||||
|
||||
when (result) {
|
||||
is NetworkResult.Success -> return result
|
||||
is NetworkResult.Failure -> {
|
||||
lastError = result.error
|
||||
if (attempt < maxRetries) {
|
||||
val delay = calculateBackoffDelay(attempt)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkResult.Failure(lastError ?: NetworkError.Unknown())
|
||||
}
|
||||
|
||||
fun fetchAndParse(url: String, httpAuth: HTTPAuthCredentials? = null): NetworkResult<ParseResult> {
|
||||
val fetchResult = fetch(url, httpAuth)
|
||||
|
||||
return fetchResult.flatMap { result ->
|
||||
try {
|
||||
val parseResult = FeedParser.parse(result.feedXml, url)
|
||||
NetworkResult.Success(parseResult)
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchSingleAttempt(
|
||||
url: String,
|
||||
httpAuth: HTTPAuthCredentials? = null,
|
||||
ifNoneMatch: String? = null,
|
||||
ifModifiedSince: String? = null
|
||||
): NetworkResult<FetchResult> {
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", "RSSuper/1.0")
|
||||
|
||||
ifNoneMatch?.let { requestBuilder.addHeader("If-None-Match", it) }
|
||||
ifModifiedSince?.let { requestBuilder.addHeader("If-Modified-Since", it) }
|
||||
|
||||
httpAuth?.let {
|
||||
requestBuilder.addHeader("Authorization", it.toCredentials())
|
||||
}
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
return try {
|
||||
val response = client.newCall(request).execute()
|
||||
handleResponse(response, url)
|
||||
} catch (e: IOException) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResponse(response: Response, url: String): NetworkResult<FetchResult> {
|
||||
try {
|
||||
val body = response.body
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
if (body != null) {
|
||||
NetworkResult.Success(FetchResult.fromResponse(response, url, response.cacheResponse != null))
|
||||
} else {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
|
||||
}
|
||||
}
|
||||
304 -> {
|
||||
if (body != null) {
|
||||
NetworkResult.Success(FetchResult.fromResponse(response, url, true))
|
||||
} else {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
|
||||
}
|
||||
}
|
||||
in 400..499 -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Client error: ${response.message}"))
|
||||
}
|
||||
in 500..599 -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Server error: ${response.message}"))
|
||||
}
|
||||
else -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Unexpected status code: ${response.code}"))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateBackoffDelay(attempt: Int): Long {
|
||||
var delay = baseRetryDelayMs
|
||||
for (i in 1 until attempt) {
|
||||
delay *= 2
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
private class TimeoutEventListener(private val call: Call) : EventListener() {
|
||||
override fun callStart(call: Call) {
|
||||
}
|
||||
|
||||
override fun callEnd(call: Call) {
|
||||
}
|
||||
|
||||
override fun callFailed(call: Call, ioe: IOException) {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NetworkResult<out T> {
|
||||
data class Success<T>(val value: T) : NetworkResult<T>()
|
||||
data class Failure<T>(val error: Throwable) : NetworkResult<T>()
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
fun isFailure(): Boolean = this is Failure
|
||||
|
||||
fun getOrNull(): T? = when (this) {
|
||||
is Success -> value
|
||||
is Failure -> null
|
||||
}
|
||||
|
||||
fun <R> map(transform: (T) -> R): NetworkResult<R> = when (this) {
|
||||
is Success -> Success(transform(value))
|
||||
is Failure -> Failure(error)
|
||||
}
|
||||
|
||||
fun <R> flatMap(transform: (T) -> NetworkResult<R>): NetworkResult<R> = when (this) {
|
||||
is Success -> transform(value)
|
||||
is Failure -> Failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
android/src/main/java/com/rssuper/services/FetchResult.kt
Normal file
31
android/src/main/java/com/rssuper/services/FetchResult.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Response
|
||||
|
||||
data class FetchResult(
|
||||
val feedXml: String,
|
||||
val url: String,
|
||||
val cacheControl: CacheControl?,
|
||||
val isCached: Boolean,
|
||||
val etag: String? = null,
|
||||
val lastModified: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromResponse(response: Response, url: String, isCached: Boolean = false): FetchResult {
|
||||
val body = response.body?.string() ?: ""
|
||||
val cacheControl = response.cacheControl
|
||||
val etag = response.header("ETag")
|
||||
val lastModified = response.header("Last-Modified")
|
||||
|
||||
return FetchResult(
|
||||
feedXml = body,
|
||||
url = url,
|
||||
cacheControl = cacheControl,
|
||||
isCached = isCached,
|
||||
etag = etag,
|
||||
lastModified = lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import okhttp3.Credentials
|
||||
|
||||
data class HTTPAuthCredentials(
|
||||
val username: String,
|
||||
val password: String
|
||||
) {
|
||||
fun toCredentials(): String {
|
||||
return Credentials.basic(username, password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.rssuper.services
|
||||
|
||||
sealed class NetworkError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
|
||||
data class Http(val statusCode: Int, override val message: String) : NetworkError(message)
|
||||
data class Timeout(val durationMs: Long) : NetworkError("Timeout")
|
||||
data class Unknown(override val cause: Throwable? = null) : NetworkError(cause = cause)
|
||||
}
|
||||
10
android/src/main/java/com/rssuper/state/BookmarkState.kt
Normal file
10
android/src/main/java/com/rssuper/state/BookmarkState.kt
Normal 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
|
||||
}
|
||||
15
android/src/main/java/com/rssuper/state/ErrorType.kt
Normal file
15
android/src/main/java/com/rssuper/state/ErrorType.kt
Normal 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
|
||||
)
|
||||
8
android/src/main/java/com/rssuper/state/State.kt
Normal file
8
android/src/main/java/com/rssuper/state/State.kt
Normal 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>
|
||||
}
|
||||
67
android/src/main/java/com/rssuper/viewmodel/FeedViewModel.kt
Normal file
67
android/src/main/java/com/rssuper/viewmodel/FeedViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
294
android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt
Normal file
294
android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt
Normal file
@@ -0,0 +1,294 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.rssuper.database.daos.FeedItemDao
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class FeedItemDaoTest {
|
||||
|
||||
private lateinit var database: RssDatabase
|
||||
private lateinit var dao: FeedItemDao
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
context,
|
||||
RssDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
dao = database.feedItemDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAndGetItem() = runTest {
|
||||
val item = createTestItem("1", "sub1")
|
||||
|
||||
dao.insertItem(item)
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertNotNull(result)
|
||||
assertEquals("1", result?.id)
|
||||
assertEquals("Test Item", result?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsBySubscription() = runTest {
|
||||
val item1 = createTestItem("1", "sub1")
|
||||
val item2 = createTestItem("2", "sub1")
|
||||
val item3 = createTestItem("3", "sub2")
|
||||
|
||||
dao.insertItems(listOf(item1, item2, item3))
|
||||
|
||||
val result = dao.getItemsBySubscription("sub1").first()
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsBySubscriptions() = runTest {
|
||||
val item1 = createTestItem("1", "sub1")
|
||||
val item2 = createTestItem("2", "sub2")
|
||||
val item3 = createTestItem("3", "sub3")
|
||||
|
||||
dao.insertItems(listOf(item1, item2, item3))
|
||||
|
||||
val result = dao.getItemsBySubscriptions(listOf("sub1", "sub2")).first()
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getUnreadItems() = runTest {
|
||||
val unread = createTestItem("1", "sub1", isRead = false)
|
||||
val read = createTestItem("2", "sub1", isRead = true)
|
||||
|
||||
dao.insertItems(listOf(unread, read))
|
||||
|
||||
val result = dao.getUnreadItems().first()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("1", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStarredItems() = runTest {
|
||||
val starred = createTestItem("1", "sub1", isStarred = true)
|
||||
val notStarred = createTestItem("2", "sub1", isStarred = false)
|
||||
|
||||
dao.insertItems(listOf(starred, notStarred))
|
||||
|
||||
val result = dao.getStarredItems().first()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("1", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsAfterDate() = runTest {
|
||||
val oldDate = Date(System.currentTimeMillis() - 86400000 * 2)
|
||||
val newDate = Date(System.currentTimeMillis() - 86400000)
|
||||
val today = Date()
|
||||
|
||||
val oldItem = createTestItem("1", "sub1", published = oldDate)
|
||||
val newItem = createTestItem("2", "sub1", published = newDate)
|
||||
val todayItem = createTestItem("3", "sub1", published = today)
|
||||
|
||||
dao.insertItems(listOf(oldItem, newItem, todayItem))
|
||||
|
||||
val result = dao.getItemsAfterDate(newDate).first()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("3", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getUnreadCount() = runTest {
|
||||
val unread1 = createTestItem("1", "sub1", isRead = false)
|
||||
val unread2 = createTestItem("2", "sub1", isRead = false)
|
||||
val read = createTestItem("3", "sub1", isRead = true)
|
||||
|
||||
dao.insertItems(listOf(unread1, unread2, read))
|
||||
|
||||
val count = dao.getUnreadCount("sub1").first()
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getTotalUnreadCount() = runTest {
|
||||
val unread1 = createTestItem("1", "sub1", isRead = false)
|
||||
val unread2 = createTestItem("2", "sub2", isRead = false)
|
||||
val read = createTestItem("3", "sub1", isRead = true)
|
||||
|
||||
dao.insertItems(listOf(unread1, unread2, read))
|
||||
|
||||
val count = dao.getTotalUnreadCount().first()
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateItem() = runTest {
|
||||
val item = createTestItem("1", "sub1")
|
||||
|
||||
dao.insertItem(item)
|
||||
|
||||
val updated = item.copy(title = "Updated Title")
|
||||
dao.updateItem(updated)
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertEquals("Updated Title", result?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteItem() = runTest {
|
||||
val item = createTestItem("1", "sub1")
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.deleteItem(item)
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteItemById() = runTest {
|
||||
val item = createTestItem("1", "sub1")
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.deleteItemById("1")
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteItemsBySubscription() = runTest {
|
||||
val item1 = createTestItem("1", "sub1")
|
||||
val item2 = createTestItem("2", "sub1")
|
||||
val item3 = createTestItem("3", "sub2")
|
||||
|
||||
dao.insertItems(listOf(item1, item2, item3))
|
||||
dao.deleteItemsBySubscription("sub1")
|
||||
|
||||
val sub1Items = dao.getItemsBySubscription("sub1").first()
|
||||
val sub2Items = dao.getItemsBySubscription("sub2").first()
|
||||
|
||||
assertEquals(0, sub1Items.size)
|
||||
assertEquals(1, sub2Items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markAsRead() = runTest {
|
||||
val item = createTestItem("1", "sub1", isRead = false)
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.markAsRead("1")
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertEquals(true, result?.isRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markAsUnread() = runTest {
|
||||
val item = createTestItem("1", "sub1", isRead = true)
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.markAsUnread("1")
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertEquals(false, result?.isRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markAsStarred() = runTest {
|
||||
val item = createTestItem("1", "sub1", isStarred = false)
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.markAsStarred("1")
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertEquals(true, result?.isStarred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markAsUnstarred() = runTest {
|
||||
val item = createTestItem("1", "sub1", isStarred = true)
|
||||
|
||||
dao.insertItem(item)
|
||||
dao.markAsUnstarred("1")
|
||||
|
||||
val result = dao.getItemById("1")
|
||||
assertEquals(false, result?.isStarred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markAllAsRead() = runTest {
|
||||
val item1 = createTestItem("1", "sub1", isRead = false)
|
||||
val item2 = createTestItem("2", "sub1", isRead = false)
|
||||
val item3 = createTestItem("3", "sub2", isRead = false)
|
||||
|
||||
dao.insertItems(listOf(item1, item2, item3))
|
||||
dao.markAllAsRead("sub1")
|
||||
|
||||
val sub1Items = dao.getItemsBySubscription("sub1").first()
|
||||
val sub2Items = dao.getItemsBySubscription("sub2").first()
|
||||
|
||||
assertEquals(true, sub1Items[0].isRead)
|
||||
assertEquals(true, sub1Items[1].isRead)
|
||||
assertEquals(false, sub2Items[0].isRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsPaginated() = runTest {
|
||||
for (i in 1..10) {
|
||||
val item = createTestItem(i.toString(), "sub1")
|
||||
dao.insertItem(item)
|
||||
}
|
||||
|
||||
val firstPage = dao.getItemsPaginated("sub1", 5, 0)
|
||||
val secondPage = dao.getItemsPaginated("sub1", 5, 5)
|
||||
|
||||
assertEquals(5, firstPage.size)
|
||||
assertEquals(5, secondPage.size)
|
||||
}
|
||||
|
||||
private fun createTestItem(
|
||||
id: String,
|
||||
subscriptionId: String,
|
||||
title: String = "Test Item",
|
||||
isRead: Boolean = false,
|
||||
isStarred: Boolean = false,
|
||||
published: Date = Date()
|
||||
): FeedItemEntity {
|
||||
return FeedItemEntity(
|
||||
id = id,
|
||||
subscriptionId = subscriptionId,
|
||||
title = title,
|
||||
link = "https://example.com/$id",
|
||||
description = "Test description",
|
||||
content = "Test content",
|
||||
author = "Test Author",
|
||||
published = published,
|
||||
updated = published,
|
||||
categories = "Tech,News",
|
||||
enclosureUrl = null,
|
||||
enclosureType = null,
|
||||
enclosureLength = null,
|
||||
guid = "guid-$id",
|
||||
isRead = isRead,
|
||||
isStarred = isStarred
|
||||
)
|
||||
}
|
||||
}
|
||||
196
android/src/test/java/com/rssuper/database/RssDatabaseTest.kt
Normal file
196
android/src/test/java/com/rssuper/database/RssDatabaseTest.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
class RssDatabaseTest {
|
||||
|
||||
private lateinit var database: RssDatabase
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
context,
|
||||
RssDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun databaseConstruction() {
|
||||
assertNotNull(database.subscriptionDao())
|
||||
assertNotNull(database.feedItemDao())
|
||||
assertNotNull(database.searchHistoryDao())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ftsVirtualTableExists() {
|
||||
val cursor = database.run {
|
||||
openHelper.writableDatabase.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'",
|
||||
emptyArray()
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(true, cursor.moveToFirst())
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionEntityRoundTrip() = runTest {
|
||||
val now = Date()
|
||||
val subscription = SubscriptionEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
url = "https://example.com/feed",
|
||||
title = "Test Feed",
|
||||
category = "Tech",
|
||||
enabled = true,
|
||||
fetchInterval = 3600000,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
lastFetchedAt = null,
|
||||
nextFetchAt = null,
|
||||
error = null,
|
||||
httpAuthUsername = null,
|
||||
httpAuthPassword = null
|
||||
)
|
||||
|
||||
database.subscriptionDao().insertSubscription(subscription)
|
||||
|
||||
val result = database.subscriptionDao().getSubscriptionById(subscription.id)
|
||||
assertNotNull(result)
|
||||
assertEquals(subscription.id, result?.id)
|
||||
assertEquals(subscription.title, result?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedItemEntityRoundTrip() = runTest {
|
||||
val now = Date()
|
||||
val subscription = SubscriptionEntity(
|
||||
id = "sub1",
|
||||
url = "https://example.com/feed",
|
||||
title = "Test Feed",
|
||||
category = "Tech",
|
||||
enabled = true,
|
||||
fetchInterval = 3600000,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
lastFetchedAt = null,
|
||||
nextFetchAt = null,
|
||||
error = null,
|
||||
httpAuthUsername = null,
|
||||
httpAuthPassword = null
|
||||
)
|
||||
database.subscriptionDao().insertSubscription(subscription)
|
||||
|
||||
val item = FeedItemEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
subscriptionId = "sub1",
|
||||
title = "Test Item",
|
||||
link = "https://example.com/item",
|
||||
description = "Test description",
|
||||
content = "Test content",
|
||||
author = "Test Author",
|
||||
published = now,
|
||||
updated = now,
|
||||
categories = "Tech",
|
||||
enclosureUrl = null,
|
||||
enclosureType = null,
|
||||
enclosureLength = null,
|
||||
guid = "guid-1",
|
||||
isRead = false,
|
||||
isStarred = false
|
||||
)
|
||||
|
||||
database.feedItemDao().insertItem(item)
|
||||
|
||||
val result = database.feedItemDao().getItemById(item.id)
|
||||
assertNotNull(result)
|
||||
assertEquals(item.id, result?.id)
|
||||
assertEquals(item.title, result?.title)
|
||||
assertEquals("sub1", result?.subscriptionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchHistoryEntityRoundTrip() = runTest {
|
||||
val now = Date()
|
||||
val search = SearchHistoryEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
query = "kotlin coroutines",
|
||||
timestamp = now
|
||||
)
|
||||
|
||||
database.searchHistoryDao().insertSearchHistory(search)
|
||||
|
||||
val result = database.searchHistoryDao().getSearchHistoryById(search.id)
|
||||
assertNotNull(result)
|
||||
assertEquals(search.id, result?.id)
|
||||
assertEquals(search.query, result?.query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cascadeDeleteFeedItems() = runTest {
|
||||
val now = Date()
|
||||
val subscription = SubscriptionEntity(
|
||||
id = "sub1",
|
||||
url = "https://example.com/feed",
|
||||
title = "Test Feed",
|
||||
category = "Tech",
|
||||
enabled = true,
|
||||
fetchInterval = 3600000,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
lastFetchedAt = null,
|
||||
nextFetchAt = null,
|
||||
error = null,
|
||||
httpAuthUsername = null,
|
||||
httpAuthPassword = null
|
||||
)
|
||||
database.subscriptionDao().insertSubscription(subscription)
|
||||
|
||||
val item = FeedItemEntity(
|
||||
id = "item1",
|
||||
subscriptionId = "sub1",
|
||||
title = "Test Item",
|
||||
link = "https://example.com/item",
|
||||
description = "Test description",
|
||||
content = "Test content",
|
||||
author = "Test Author",
|
||||
published = now,
|
||||
updated = now,
|
||||
categories = "Tech",
|
||||
enclosureUrl = null,
|
||||
enclosureType = null,
|
||||
enclosureLength = null,
|
||||
guid = "guid-1",
|
||||
isRead = false,
|
||||
isStarred = false
|
||||
)
|
||||
database.feedItemDao().insertItem(item)
|
||||
|
||||
database.subscriptionDao().deleteSubscription(subscription)
|
||||
|
||||
val items = database.feedItemDao().getItemsBySubscription("sub1").first()
|
||||
assertEquals(0, items.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.rssuper.database.daos.SearchHistoryDao
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class SearchHistoryDaoTest {
|
||||
|
||||
private lateinit var database: RssDatabase
|
||||
private lateinit var dao: SearchHistoryDao
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
context,
|
||||
RssDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
dao = database.searchHistoryDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAndGetSearchHistory() = runTest {
|
||||
val search = createTestSearch("1", "kotlin")
|
||||
|
||||
dao.insertSearchHistory(search)
|
||||
|
||||
val result = dao.getSearchHistoryById("1")
|
||||
assertNotNull(result)
|
||||
assertEquals("1", result?.id)
|
||||
assertEquals("kotlin", result?.query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllSearchHistory() = runTest {
|
||||
val search1 = createTestSearch("1", "kotlin")
|
||||
val search2 = createTestSearch("2", "android")
|
||||
val search3 = createTestSearch("3", "room database")
|
||||
|
||||
dao.insertSearchHistories(listOf(search1, search2, search3))
|
||||
|
||||
val result = dao.getAllSearchHistory().first()
|
||||
assertEquals(3, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchHistory() = runTest {
|
||||
val search1 = createTestSearch("1", "kotlin coroutines")
|
||||
val search2 = createTestSearch("2", "android kotlin")
|
||||
val search3 = createTestSearch("3", "java")
|
||||
|
||||
dao.insertSearchHistories(listOf(search1, search2, search3))
|
||||
|
||||
val result = dao.searchHistory("kotlin").first()
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecentSearches() = runTest {
|
||||
val search1 = createTestSearch("1", "query1", timestamp = Date(System.currentTimeMillis() - 300000))
|
||||
val search2 = createTestSearch("2", "query2", timestamp = Date(System.currentTimeMillis() - 200000))
|
||||
val search3 = createTestSearch("3", "query3", timestamp = Date(System.currentTimeMillis() - 100000))
|
||||
|
||||
dao.insertSearchHistories(listOf(search1, search2, search3))
|
||||
|
||||
val result = dao.getRecentSearches(2).first()
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("3", result[0].id)
|
||||
assertEquals("2", result[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSearchHistoryCount() = runTest {
|
||||
val search1 = createTestSearch("1", "query1")
|
||||
val search2 = createTestSearch("2", "query2")
|
||||
val search3 = createTestSearch("3", "query3")
|
||||
|
||||
dao.insertSearchHistories(listOf(search1, search2, search3))
|
||||
|
||||
val count = dao.getSearchHistoryCount().first()
|
||||
assertEquals(3, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateSearchHistory() = runTest {
|
||||
val search = createTestSearch("1", "old query")
|
||||
|
||||
dao.insertSearchHistory(search)
|
||||
|
||||
val updated = search.copy(query = "new query")
|
||||
dao.updateSearchHistory(updated)
|
||||
|
||||
val result = dao.getSearchHistoryById("1")
|
||||
assertEquals("new query", result?.query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSearchHistory() = runTest {
|
||||
val search = createTestSearch("1", "kotlin")
|
||||
|
||||
dao.insertSearchHistory(search)
|
||||
dao.deleteSearchHistory(search)
|
||||
|
||||
val result = dao.getSearchHistoryById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSearchHistoryById() = runTest {
|
||||
val search = createTestSearch("1", "kotlin")
|
||||
|
||||
dao.insertSearchHistory(search)
|
||||
dao.deleteSearchHistoryById("1")
|
||||
|
||||
val result = dao.getSearchHistoryById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllSearchHistory() = runTest {
|
||||
val search1 = createTestSearch("1", "query1")
|
||||
val search2 = createTestSearch("2", "query2")
|
||||
|
||||
dao.insertSearchHistories(listOf(search1, search2))
|
||||
dao.deleteAllSearchHistory()
|
||||
|
||||
val result = dao.getAllSearchHistory().first()
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSearchHistoryOlderThan() = runTest {
|
||||
val oldSearch = createTestSearch("1", "old query", timestamp = Date(System.currentTimeMillis() - 86400000 * 2))
|
||||
val recentSearch = createTestSearch("2", "recent query", timestamp = Date(System.currentTimeMillis() - 86400000))
|
||||
|
||||
dao.insertSearchHistories(listOf(oldSearch, recentSearch))
|
||||
dao.deleteSearchHistoryOlderThan(System.currentTimeMillis() - 86400000)
|
||||
|
||||
val result = dao.getAllSearchHistory().first()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("2", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertSearchHistoryWithConflict() = runTest {
|
||||
val search = createTestSearch("1", "kotlin")
|
||||
|
||||
dao.insertSearchHistory(search)
|
||||
|
||||
val duplicate = search.copy(query = "android")
|
||||
val result = dao.insertSearchHistory(duplicate)
|
||||
|
||||
assertEquals(-1L, result)
|
||||
|
||||
val dbSearch = dao.getSearchHistoryById("1")
|
||||
assertEquals("kotlin", dbSearch?.query)
|
||||
}
|
||||
|
||||
private fun createTestSearch(
|
||||
id: String,
|
||||
query: String,
|
||||
timestamp: Date = Date()
|
||||
): SearchHistoryEntity {
|
||||
return SearchHistoryEntity(
|
||||
id = id,
|
||||
query = query,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.rssuper.database.daos.SubscriptionDao
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class SubscriptionDaoTest {
|
||||
|
||||
private lateinit var database: RssDatabase
|
||||
private lateinit var dao: SubscriptionDao
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
context,
|
||||
RssDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
dao = database.subscriptionDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAndGetSubscription() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertNotNull(result)
|
||||
assertEquals("1", result?.id)
|
||||
assertEquals("Test Feed", result?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSubscriptionByUrl() = runTest {
|
||||
val subscription = createTestSubscription("1", url = "https://example.com/feed")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
|
||||
val result = dao.getSubscriptionByUrl("https://example.com/feed")
|
||||
assertNotNull(result)
|
||||
assertEquals("1", result?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllSubscriptions() = runTest {
|
||||
val subscription1 = createTestSubscription("1")
|
||||
val subscription2 = createTestSubscription("2")
|
||||
|
||||
dao.insertSubscriptions(listOf(subscription1, subscription2))
|
||||
|
||||
val result = dao.getAllSubscriptions().first()
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getEnabledSubscriptions() = runTest {
|
||||
val enabled = createTestSubscription("1", enabled = true)
|
||||
val disabled = createTestSubscription("2", enabled = false)
|
||||
|
||||
dao.insertSubscriptions(listOf(enabled, disabled))
|
||||
|
||||
val result = dao.getEnabledSubscriptions().first()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("1", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateSubscription() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
|
||||
val updated = subscription.copy(title = "Updated Title")
|
||||
dao.updateSubscription(updated)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertEquals("Updated Title", result?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSubscription() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
dao.deleteSubscription(subscription)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSubscriptionById() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
dao.deleteSubscriptionById("1")
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSubscriptionCount() = runTest {
|
||||
val subscription1 = createTestSubscription("1")
|
||||
val subscription2 = createTestSubscription("2")
|
||||
|
||||
dao.insertSubscriptions(listOf(subscription1, subscription2))
|
||||
|
||||
val count = dao.getSubscriptionCount().first()
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateError() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
dao.updateError("1", "Feed not found")
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertEquals("Feed not found", result?.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateLastFetchedAt() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
val now = Date()
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
dao.updateLastFetchedAt("1", now)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertEquals(now, result?.lastFetchedAt)
|
||||
assertNull(result?.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateNextFetchAt() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
val future = Date(System.currentTimeMillis() + 3600000)
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
dao.updateNextFetchAt("1", future)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertEquals(future, result?.nextFetchAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertSubscriptionWithConflict() = runTest {
|
||||
val subscription = createTestSubscription("1")
|
||||
|
||||
dao.insertSubscription(subscription)
|
||||
|
||||
val updated = subscription.copy(title = "Updated")
|
||||
dao.insertSubscription(updated)
|
||||
|
||||
val result = dao.getSubscriptionById("1")
|
||||
assertEquals("Updated", result?.title)
|
||||
}
|
||||
|
||||
private fun createTestSubscription(
|
||||
id: String,
|
||||
url: String = "https://example.com/feed/$id",
|
||||
title: String = "Test Feed",
|
||||
enabled: Boolean = true
|
||||
): SubscriptionEntity {
|
||||
val now = Date()
|
||||
return SubscriptionEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
title = title,
|
||||
category = "Tech",
|
||||
enabled = enabled,
|
||||
fetchInterval = 3600000,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
lastFetchedAt = null,
|
||||
nextFetchAt = null,
|
||||
error = null,
|
||||
httpAuthUsername = null,
|
||||
httpAuthPassword = null
|
||||
)
|
||||
}
|
||||
}
|
||||
134
android/src/test/java/com/rssuper/models/FeedItemTest.kt
Normal file
134
android/src/test/java/com/rssuper/models/FeedItemTest.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class FeedItemTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedItem>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(FeedItem::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val feedItem = FeedItem(
|
||||
id = "item-1",
|
||||
title = "Test Article",
|
||||
link = "https://example.com/article",
|
||||
description = "Short description",
|
||||
content = "Full content here",
|
||||
author = "John Doe",
|
||||
published = Date(1672531200000),
|
||||
categories = listOf("Tech", "News"),
|
||||
guid = "guid-123",
|
||||
subscriptionTitle = "Tech News"
|
||||
)
|
||||
|
||||
val json = adapter.toJson(feedItem)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"id": "item-1",
|
||||
"title": "Test Article",
|
||||
"link": "https://example.com/article",
|
||||
"description": "Short description",
|
||||
"author": "John Doe",
|
||||
"published": 1672531200000,
|
||||
"categories": ["Tech", "News"],
|
||||
"guid": "guid-123",
|
||||
"subscriptionTitle": "Tech News"
|
||||
}"""
|
||||
|
||||
val feedItem = adapter.fromJson(json)
|
||||
assertNotNull(feedItem)
|
||||
assertEquals("item-1", feedItem?.id)
|
||||
assertEquals("Test Article", feedItem?.title)
|
||||
assertEquals("John Doe", feedItem?.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptionalFieldsNull() {
|
||||
val json = """{
|
||||
"id": "item-1",
|
||||
"title": "Test Article"
|
||||
}"""
|
||||
|
||||
val feedItem = adapter.fromJson(json)
|
||||
assertNotNull(feedItem)
|
||||
assertNull(feedItem?.link)
|
||||
assertNull(feedItem?.description)
|
||||
assertNull(feedItem?.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEnclosureSerialization() {
|
||||
val feedItem = FeedItem(
|
||||
id = "item-1",
|
||||
title = "Podcast Episode",
|
||||
enclosure = Enclosure(
|
||||
url = "https://example.com/episode.mp3",
|
||||
type = "audio/mpeg",
|
||||
length = 12345678
|
||||
)
|
||||
)
|
||||
|
||||
val json = adapter.toJson(feedItem)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = FeedItem(
|
||||
id = "item-1",
|
||||
title = "Original Title",
|
||||
author = "Original Author"
|
||||
)
|
||||
|
||||
val modified = original.copy(title = "Modified Title")
|
||||
|
||||
assertEquals("item-1", modified.id)
|
||||
assertEquals("Modified Title", modified.title)
|
||||
assertEquals("Original Author", modified.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val item1 = FeedItem(id = "item-1", title = "Test")
|
||||
val item2 = FeedItem(id = "item-1", title = "Test")
|
||||
val item3 = FeedItem(id = "item-2", title = "Test")
|
||||
|
||||
assertEquals(item1, item2)
|
||||
assertEquals(item1.hashCode(), item2.hashCode())
|
||||
assert(item1 != item3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val feedItem = FeedItem(
|
||||
id = "item-1",
|
||||
title = "Test Article",
|
||||
author = "John Doe"
|
||||
)
|
||||
|
||||
val toString = feedItem.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("id=item-1"))
|
||||
assert(toString.contains("title=Test Article"))
|
||||
}
|
||||
}
|
||||
199
android/src/test/java/com/rssuper/models/FeedSubscriptionTest.kt
Normal file
199
android/src/test/java/com/rssuper/models/FeedSubscriptionTest.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class FeedSubscriptionTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedSubscription>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(FeedSubscription::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val subscription = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com/feed.xml",
|
||||
title = "Tech News",
|
||||
category = "Technology",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
|
||||
val json = adapter.toJson(subscription)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"id": "sub-1",
|
||||
"url": "https://example.com/feed.xml",
|
||||
"title": "Tech News",
|
||||
"category": "Technology",
|
||||
"enabled": true,
|
||||
"fetchInterval": 60,
|
||||
"createdAt": 1672531200000,
|
||||
"updatedAt": 1672617600000
|
||||
}"""
|
||||
|
||||
val subscription = adapter.fromJson(json)
|
||||
assertNotNull(subscription)
|
||||
assertEquals("sub-1", subscription?.id)
|
||||
assertEquals("https://example.com/feed.xml", subscription?.url)
|
||||
assertEquals("Tech News", subscription?.title)
|
||||
assertEquals("Technology", subscription?.category)
|
||||
assertEquals(true, subscription?.enabled)
|
||||
assertEquals(60, subscription?.fetchInterval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptionalFieldsNull() {
|
||||
val json = """{
|
||||
"id": "sub-1",
|
||||
"url": "https://example.com/feed.xml",
|
||||
"title": "Tech News",
|
||||
"enabled": true,
|
||||
"fetchInterval": 60,
|
||||
"createdAt": 1672531200000,
|
||||
"updatedAt": 1672617600000
|
||||
}"""
|
||||
|
||||
val subscription = adapter.fromJson(json)
|
||||
assertNotNull(subscription)
|
||||
assertNull(subscription?.category)
|
||||
assertNull(subscription?.error)
|
||||
assertNull(subscription?.httpAuth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpAuthSerialization() {
|
||||
val subscription = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com/feed.xml",
|
||||
title = "Private Feed",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000),
|
||||
httpAuth = HttpAuth(
|
||||
username = "user123",
|
||||
password = "pass456"
|
||||
)
|
||||
)
|
||||
|
||||
val json = adapter.toJson(subscription)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpAuthDeserialization() {
|
||||
val json = """{
|
||||
"id": "sub-1",
|
||||
"url": "https://example.com/feed.xml",
|
||||
"title": "Private Feed",
|
||||
"enabled": true,
|
||||
"fetchInterval": 60,
|
||||
"createdAt": 1672531200000,
|
||||
"updatedAt": 1672617600000,
|
||||
"httpAuth": {
|
||||
"username": "user123",
|
||||
"password": "pass456"
|
||||
}
|
||||
}"""
|
||||
|
||||
val subscription = adapter.fromJson(json)
|
||||
assertNotNull(subscription)
|
||||
assertNotNull(subscription?.httpAuth)
|
||||
assertEquals("user123", subscription?.httpAuth?.username)
|
||||
assertEquals("pass456", subscription?.httpAuth?.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com/feed.xml",
|
||||
title = "Original Title",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
|
||||
val modified = original.copy(title = "Modified Title", enabled = false)
|
||||
|
||||
assertEquals("sub-1", modified.id)
|
||||
assertEquals("Modified Title", modified.title)
|
||||
assertEquals(false, modified.enabled)
|
||||
assertEquals(60, modified.fetchInterval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val sub1 = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com",
|
||||
title = "Test",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
val sub2 = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com",
|
||||
title = "Test",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
val sub3 = FeedSubscription(
|
||||
id = "sub-2",
|
||||
url = "https://example.com",
|
||||
title = "Test",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
|
||||
assertEquals(sub1, sub2)
|
||||
assertEquals(sub1.hashCode(), sub2.hashCode())
|
||||
assert(sub1 != sub3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val subscription = FeedSubscription(
|
||||
id = "sub-1",
|
||||
url = "https://example.com/feed.xml",
|
||||
title = "Tech News",
|
||||
enabled = true,
|
||||
fetchInterval = 60,
|
||||
createdAt = Date(1672531200000),
|
||||
updatedAt = Date(1672617600000)
|
||||
)
|
||||
|
||||
val toString = subscription.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("id=sub-1"))
|
||||
assert(toString.contains("title=Tech News"))
|
||||
}
|
||||
}
|
||||
139
android/src/test/java/com/rssuper/models/FeedTest.kt
Normal file
139
android/src/test/java/com/rssuper/models/FeedTest.kt
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class FeedTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<Feed>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(Feed::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val feed = Feed(
|
||||
id = "feed-1",
|
||||
title = "Tech News",
|
||||
link = "https://example.com",
|
||||
description = "Technology news feed",
|
||||
subtitle = "Daily tech updates",
|
||||
language = "en",
|
||||
rawUrl = "https://example.com/feed.xml",
|
||||
ttl = 60,
|
||||
items = listOf(
|
||||
FeedItem(id = "item-1", title = "Article 1"),
|
||||
FeedItem(id = "item-2", title = "Article 2")
|
||||
)
|
||||
)
|
||||
|
||||
val json = adapter.toJson(feed)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"id": "feed-1",
|
||||
"title": "Tech News",
|
||||
"link": "https://example.com",
|
||||
"description": "Technology news feed",
|
||||
"subtitle": "Daily tech updates",
|
||||
"language": "en",
|
||||
"rawUrl": "https://example.com/feed.xml",
|
||||
"ttl": 60,
|
||||
"items": [
|
||||
{"id": "item-1", "title": "Article 1"},
|
||||
{"id": "item-2", "title": "Article 2"}
|
||||
]
|
||||
}"""
|
||||
|
||||
val feed = adapter.fromJson(json)
|
||||
assertNotNull(feed)
|
||||
assertEquals("feed-1", feed?.id)
|
||||
assertEquals("Tech News", feed?.title)
|
||||
assertEquals(2, feed?.items?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptionalFieldsNull() {
|
||||
val json = """{
|
||||
"id": "feed-1",
|
||||
"title": "Tech News",
|
||||
"rawUrl": "https://example.com/feed.xml"
|
||||
}"""
|
||||
|
||||
val feed = adapter.fromJson(json)
|
||||
assertNotNull(feed)
|
||||
assertNull(feed?.link)
|
||||
assertNull(feed?.description)
|
||||
assertNull(feed?.language)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyItemsList() {
|
||||
val json = """{
|
||||
"id": "feed-1",
|
||||
"title": "Tech News",
|
||||
"rawUrl": "https://example.com/feed.xml",
|
||||
"items": []
|
||||
}"""
|
||||
|
||||
val feed = adapter.fromJson(json)
|
||||
assertNotNull(feed)
|
||||
assertTrue(feed?.items?.isEmpty() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = Feed(
|
||||
id = "feed-1",
|
||||
title = "Original Title",
|
||||
rawUrl = "https://example.com/feed.xml"
|
||||
)
|
||||
|
||||
val modified = original.copy(title = "Modified Title")
|
||||
|
||||
assertEquals("feed-1", modified.id)
|
||||
assertEquals("Modified Title", modified.title)
|
||||
assertEquals("https://example.com/feed.xml", modified.rawUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val feed1 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
|
||||
val feed2 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
|
||||
val feed3 = Feed(id = "feed-2", title = "Test", rawUrl = "https://example.com")
|
||||
|
||||
assertEquals(feed1, feed2)
|
||||
assertEquals(feed1.hashCode(), feed2.hashCode())
|
||||
assert(feed1 != feed3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val feed = Feed(
|
||||
id = "feed-1",
|
||||
title = "Tech News",
|
||||
rawUrl = "https://example.com/feed.xml"
|
||||
)
|
||||
|
||||
val toString = feed.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("id=feed-1"))
|
||||
assert(toString.contains("title=Tech News"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NotificationPreferencesTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<NotificationPreferences>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(NotificationPreferences::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val preferences = NotificationPreferences(
|
||||
newArticles = true,
|
||||
episodeReleases = true,
|
||||
customAlerts = false,
|
||||
badgeCount = true,
|
||||
sound = true,
|
||||
vibration = false
|
||||
)
|
||||
|
||||
val json = adapter.toJson(preferences)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"newArticles": true,
|
||||
"episodeReleases": true,
|
||||
"customAlerts": false,
|
||||
"badgeCount": true,
|
||||
"sound": true,
|
||||
"vibration": false
|
||||
}"""
|
||||
|
||||
val preferences = adapter.fromJson(json)
|
||||
assertNotNull(preferences)
|
||||
assertEquals(true, preferences?.newArticles)
|
||||
assertEquals(true, preferences?.episodeReleases)
|
||||
assertEquals(false, preferences?.customAlerts)
|
||||
assertEquals(true, preferences?.badgeCount)
|
||||
assertEquals(true, preferences?.sound)
|
||||
assertEquals(false, preferences?.vibration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDefaultValues() {
|
||||
val preferences = NotificationPreferences()
|
||||
|
||||
assertEquals(true, preferences.newArticles)
|
||||
assertEquals(true, preferences.episodeReleases)
|
||||
assertEquals(false, preferences.customAlerts)
|
||||
assertEquals(true, preferences.badgeCount)
|
||||
assertEquals(true, preferences.sound)
|
||||
assertEquals(true, preferences.vibration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = NotificationPreferences(
|
||||
newArticles = true,
|
||||
sound = true
|
||||
)
|
||||
|
||||
val modified = original.copy(newArticles = false, sound = false)
|
||||
|
||||
assertEquals(false, modified.newArticles)
|
||||
assertEquals(false, modified.sound)
|
||||
assertEquals(true, modified.episodeReleases)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val pref1 = NotificationPreferences(newArticles = true, sound = true)
|
||||
val pref2 = NotificationPreferences(newArticles = true, sound = true)
|
||||
val pref3 = NotificationPreferences(newArticles = false, sound = true)
|
||||
|
||||
assertEquals(pref1, pref2)
|
||||
assertEquals(pref1.hashCode(), pref2.hashCode())
|
||||
assert(pref1 != pref3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val preferences = NotificationPreferences(
|
||||
newArticles = true,
|
||||
sound = true
|
||||
)
|
||||
|
||||
val toString = preferences.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("newArticles"))
|
||||
assert(toString.contains("sound"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ReadingPreferencesTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<ReadingPreferences>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(ReadingPreferences::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val preferences = ReadingPreferences(
|
||||
fontSize = FontSize.LARGE,
|
||||
lineHeight = LineHeight.RELAXED,
|
||||
showTableOfContents = true,
|
||||
showReadingTime = true,
|
||||
showAuthor = false,
|
||||
showDate = true
|
||||
)
|
||||
|
||||
val json = adapter.toJson(preferences)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"fontSize": "large",
|
||||
"lineHeight": "relaxed",
|
||||
"showTableOfContents": true,
|
||||
"showReadingTime": true,
|
||||
"showAuthor": false,
|
||||
"showDate": true
|
||||
}"""
|
||||
|
||||
val preferences = adapter.fromJson(json)
|
||||
assertNotNull(preferences)
|
||||
assertEquals(FontSize.LARGE, preferences?.fontSize)
|
||||
assertEquals(LineHeight.RELAXED, preferences?.lineHeight)
|
||||
assertEquals(true, preferences?.showTableOfContents)
|
||||
assertEquals(true, preferences?.showReadingTime)
|
||||
assertEquals(false, preferences?.showAuthor)
|
||||
assertEquals(true, preferences?.showDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFontSizeOptions() {
|
||||
val fontSizes = listOf(
|
||||
"small" to FontSize.SMALL,
|
||||
"medium" to FontSize.MEDIUM,
|
||||
"large" to FontSize.LARGE,
|
||||
"xlarge" to FontSize.XLARGE
|
||||
)
|
||||
|
||||
for ((jsonValue, expectedEnum) in fontSizes) {
|
||||
val json = """{"fontSize": "$jsonValue"}"""
|
||||
val preferences = adapter.fromJson(json)
|
||||
assertNotNull("Failed for fontSize: $jsonValue", preferences)
|
||||
assertEquals("Failed for fontSize: $jsonValue", expectedEnum, preferences?.fontSize)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLineHeightOptions() {
|
||||
val lineHeights = listOf(
|
||||
"normal" to LineHeight.NORMAL,
|
||||
"relaxed" to LineHeight.RELAXED,
|
||||
"loose" to LineHeight.LOOSE
|
||||
)
|
||||
|
||||
for ((jsonValue, expectedEnum) in lineHeights) {
|
||||
val json = """{"lineHeight": "$jsonValue"}"""
|
||||
val preferences = adapter.fromJson(json)
|
||||
assertNotNull("Failed for lineHeight: $jsonValue", preferences)
|
||||
assertEquals("Failed for lineHeight: $jsonValue", expectedEnum, preferences?.lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDefaultValues() {
|
||||
val preferences = ReadingPreferences()
|
||||
|
||||
assertEquals(FontSize.MEDIUM, preferences.fontSize)
|
||||
assertEquals(LineHeight.NORMAL, preferences.lineHeight)
|
||||
assertEquals(false, preferences.showTableOfContents)
|
||||
assertEquals(true, preferences.showReadingTime)
|
||||
assertEquals(true, preferences.showAuthor)
|
||||
assertEquals(true, preferences.showDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = ReadingPreferences(
|
||||
fontSize = FontSize.MEDIUM,
|
||||
showReadingTime = true
|
||||
)
|
||||
|
||||
val modified = original.copy(fontSize = FontSize.XLARGE, showReadingTime = false)
|
||||
|
||||
assertEquals(FontSize.XLARGE, modified.fontSize)
|
||||
assertEquals(false, modified.showReadingTime)
|
||||
assertEquals(LineHeight.NORMAL, modified.lineHeight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val pref1 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
|
||||
val pref2 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
|
||||
val pref3 = ReadingPreferences(fontSize = FontSize.LARGE, showReadingTime = true)
|
||||
|
||||
assertEquals(pref1, pref2)
|
||||
assertEquals(pref1.hashCode(), pref2.hashCode())
|
||||
assert(pref1 != pref3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val preferences = ReadingPreferences(
|
||||
fontSize = FontSize.LARGE,
|
||||
showReadingTime = true
|
||||
)
|
||||
|
||||
val toString = preferences.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("fontSize"))
|
||||
assert(toString.contains("showReadingTime"))
|
||||
}
|
||||
}
|
||||
156
android/src/test/java/com/rssuper/models/SearchFiltersTest.kt
Normal file
156
android/src/test/java/com/rssuper/models/SearchFiltersTest.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class SearchFiltersTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchFilters>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(SearchFilters::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val filters = SearchFilters(
|
||||
dateFrom = Date(1672531200000),
|
||||
dateTo = Date(1672617600000),
|
||||
feedIds = listOf("feed-1", "feed-2"),
|
||||
authors = listOf("John Doe", "Jane Smith"),
|
||||
contentType = ContentType.ARTICLE,
|
||||
sortOption = SearchSortOption.DATE_DESC
|
||||
)
|
||||
|
||||
val json = adapter.toJson(filters)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeserialization() {
|
||||
val json = """{
|
||||
"dateFrom": 1672531200000,
|
||||
"dateTo": 1672617600000,
|
||||
"feedIds": ["feed-1", "feed-2"],
|
||||
"authors": ["John Doe", "Jane Smith"],
|
||||
"contentType": "article",
|
||||
"sortOption": "date_desc"
|
||||
}"""
|
||||
|
||||
val filters = adapter.fromJson(json)
|
||||
assertNotNull(filters)
|
||||
assertNotNull(filters?.dateFrom)
|
||||
assertNotNull(filters?.dateTo)
|
||||
assertEquals(2, filters?.feedIds?.size)
|
||||
assertEquals(2, filters?.authors?.size)
|
||||
assertEquals(ContentType.ARTICLE, filters?.contentType)
|
||||
assertEquals(SearchSortOption.DATE_DESC, filters?.sortOption)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testContentTypeAudio() {
|
||||
val json = """{
|
||||
"contentType": "audio"
|
||||
}"""
|
||||
|
||||
val filters = adapter.fromJson(json)
|
||||
assertNotNull(filters)
|
||||
assertEquals(ContentType.AUDIO, filters?.contentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testContentTypeVideo() {
|
||||
val json = """{
|
||||
"contentType": "video"
|
||||
}"""
|
||||
|
||||
val filters = adapter.fromJson(json)
|
||||
assertNotNull(filters)
|
||||
assertEquals(ContentType.VIDEO, filters?.contentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSortOptions() {
|
||||
val sortOptions = listOf(
|
||||
"relevance" to SearchSortOption.RELEVANCE,
|
||||
"date_desc" to SearchSortOption.DATE_DESC,
|
||||
"date_asc" to SearchSortOption.DATE_ASC,
|
||||
"title_asc" to SearchSortOption.TITLE_ASC,
|
||||
"title_desc" to SearchSortOption.TITLE_DESC,
|
||||
"feed_asc" to SearchSortOption.FEED_ASC,
|
||||
"feed_desc" to SearchSortOption.FEED_DESC
|
||||
)
|
||||
|
||||
for ((jsonValue, expectedEnum) in sortOptions) {
|
||||
val json = """{"sortOption": "$jsonValue"}"""
|
||||
val filters = adapter.fromJson(json)
|
||||
assertNotNull("Failed for sortOption: $jsonValue", filters)
|
||||
assertEquals("Failed for sortOption: $jsonValue", expectedEnum, filters?.sortOption)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptionalFieldsNull() {
|
||||
val json = "{}"
|
||||
|
||||
val filters = adapter.fromJson(json)
|
||||
assertNotNull(filters)
|
||||
assertNull(filters?.dateFrom)
|
||||
assertNull(filters?.dateTo)
|
||||
assertNull(filters?.feedIds)
|
||||
assertNull(filters?.authors)
|
||||
assertNull(filters?.contentType)
|
||||
assertEquals(SearchSortOption.RELEVANCE, filters?.sortOption)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = SearchFilters(
|
||||
feedIds = listOf("feed-1"),
|
||||
sortOption = SearchSortOption.RELEVANCE
|
||||
)
|
||||
|
||||
val modified = original.copy(
|
||||
feedIds = listOf("feed-1", "feed-2"),
|
||||
sortOption = SearchSortOption.DATE_DESC
|
||||
)
|
||||
|
||||
assertEquals(2, modified.feedIds?.size)
|
||||
assertEquals(SearchSortOption.DATE_DESC, modified.sortOption)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val filters1 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
|
||||
val filters2 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
|
||||
val filters3 = SearchFilters(feedIds = listOf("feed-2"), sortOption = SearchSortOption.RELEVANCE)
|
||||
|
||||
assertEquals(filters1, filters2)
|
||||
assertEquals(filters1.hashCode(), filters2.hashCode())
|
||||
assert(filters1 != filters3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val filters = SearchFilters(
|
||||
feedIds = listOf("feed-1"),
|
||||
sortOption = SearchSortOption.DATE_DESC
|
||||
)
|
||||
|
||||
val toString = filters.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("feedIds"))
|
||||
assert(toString.contains("sortOption"))
|
||||
}
|
||||
}
|
||||
153
android/src/test/java/com/rssuper/models/SearchResultTest.kt
Normal file
153
android/src/test/java/com/rssuper/models/SearchResultTest.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package com.rssuper.models
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class SearchResultTest {
|
||||
|
||||
private lateinit var moshi: Moshi
|
||||
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchResult>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
adapter = moshi.adapter(SearchResult::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testArticleSerialization() {
|
||||
val result = SearchResult(
|
||||
id = "article-1",
|
||||
type = SearchResultType.ARTICLE,
|
||||
title = "Test Article",
|
||||
snippet = "This is a snippet",
|
||||
link = "https://example.com/article",
|
||||
feedTitle = "Tech News",
|
||||
published = Date(1672531200000),
|
||||
score = 0.95
|
||||
)
|
||||
|
||||
val json = adapter.toJson(result)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFeedSerialization() {
|
||||
val result = SearchResult(
|
||||
id = "feed-1",
|
||||
type = SearchResultType.FEED,
|
||||
title = "Tech News Feed",
|
||||
snippet = "Technology news and updates",
|
||||
link = "https://example.com",
|
||||
score = 0.85
|
||||
)
|
||||
|
||||
val json = adapter.toJson(result)
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testArticleDeserialization() {
|
||||
val json = """{
|
||||
"id": "article-1",
|
||||
"type": "article",
|
||||
"title": "Test Article",
|
||||
"snippet": "This is a snippet",
|
||||
"link": "https://example.com/article",
|
||||
"feedTitle": "Tech News",
|
||||
"published": 1672531200000,
|
||||
"score": 0.95
|
||||
}"""
|
||||
|
||||
val result = adapter.fromJson(json)
|
||||
assertNotNull(result)
|
||||
assertEquals("article-1", result?.id)
|
||||
assertEquals(SearchResultType.ARTICLE, result?.type)
|
||||
assertEquals("Test Article", result?.title)
|
||||
assertEquals("This is a snippet", result?.snippet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFeedDeserialization() {
|
||||
val json = """{
|
||||
"id": "feed-1",
|
||||
"type": "feed",
|
||||
"title": "Tech News Feed",
|
||||
"snippet": "Technology news and updates",
|
||||
"link": "https://example.com",
|
||||
"score": 0.85
|
||||
}"""
|
||||
|
||||
val result = adapter.fromJson(json)
|
||||
assertNotNull(result)
|
||||
assertEquals("feed-1", result?.id)
|
||||
assertEquals(SearchResultType.FEED, result?.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptionalFieldsNull() {
|
||||
val json = """{
|
||||
"id": "article-1",
|
||||
"type": "article",
|
||||
"title": "Test Article"
|
||||
}"""
|
||||
|
||||
val result = adapter.fromJson(json)
|
||||
assertNotNull(result)
|
||||
assertNull(result?.snippet)
|
||||
assertNull(result?.link)
|
||||
assertNull(result?.feedTitle)
|
||||
assertNull(result?.published)
|
||||
assertNull(result?.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val original = SearchResult(
|
||||
id = "article-1",
|
||||
type = SearchResultType.ARTICLE,
|
||||
title = "Original Title"
|
||||
)
|
||||
|
||||
val modified = original.copy(title = "Modified Title", score = 0.99)
|
||||
|
||||
assertEquals("article-1", modified.id)
|
||||
assertEquals(SearchResultType.ARTICLE, modified.type)
|
||||
assertEquals("Modified Title", modified.title)
|
||||
assertEquals(0.99, modified.score!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEqualsAndHashCode() {
|
||||
val result1 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
|
||||
val result2 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
|
||||
val result3 = SearchResult(id = "article-2", type = SearchResultType.ARTICLE, title = "Test")
|
||||
|
||||
assertEquals(result1, result2)
|
||||
assertEquals(result1.hashCode(), result2.hashCode())
|
||||
assert(result1 != result3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
val result = SearchResult(
|
||||
id = "article-1",
|
||||
type = SearchResultType.ARTICLE,
|
||||
title = "Test Article",
|
||||
score = 0.95
|
||||
)
|
||||
|
||||
val toString = result.toString()
|
||||
assertNotNull(toString)
|
||||
assert(toString.contains("id=article-1"))
|
||||
assert(toString.contains("title=Test Article"))
|
||||
}
|
||||
}
|
||||
245
android/src/test/java/com/rssuper/parsing/AtomParserTest.kt
Normal file
245
android/src/test/java/com/rssuper/parsing/AtomParserTest.kt
Normal file
@@ -0,0 +1,245 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Enclosure
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [24])
|
||||
class AtomParserTest {
|
||||
|
||||
@Test
|
||||
fun testParseBasicAtom() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom Feed</title>
|
||||
<subtitle>Feed subtitle</subtitle>
|
||||
<link href="https://example.com" rel="alternate"/>
|
||||
<id>urn:uuid:feed-id-123</id>
|
||||
<updated>2024-01-01T12:00:00Z</updated>
|
||||
<generator>Atom Generator</generator>
|
||||
<entry>
|
||||
<title>Entry 1</title>
|
||||
<link href="https://example.com/entry1" rel="alternate"/>
|
||||
<id>urn:uuid:entry-1</id>
|
||||
<updated>2024-01-01T10:00:00Z</updated>
|
||||
<summary>Summary of entry 1</summary>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Entry 2</title>
|
||||
<link href="https://example.com/entry2" rel="alternate"/>
|
||||
<id>urn:uuid:entry-2</id>
|
||||
<updated>2023-12-31T10:00:00Z</updated>
|
||||
<content>Full content of entry 2</content>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Atom Feed", feed.title)
|
||||
assertEquals("https://example.com", feed.link)
|
||||
assertEquals("Feed subtitle", feed.subtitle)
|
||||
assertEquals(2, feed.items.size)
|
||||
|
||||
val entry1 = feed.items[0]
|
||||
assertEquals("Entry 1", entry1.title)
|
||||
assertEquals("https://example.com/entry1", entry1.link)
|
||||
assertEquals("Summary of entry 1", entry1.description)
|
||||
assertNotNull(entry1.published)
|
||||
|
||||
val entry2 = feed.items[1]
|
||||
assertEquals("Entry 2", entry2.title)
|
||||
assertEquals("Full content of entry 2", entry2.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithAuthor() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Author Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<entry>
|
||||
<title>Entry with Author</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<author>
|
||||
<name>John Doe</name>
|
||||
<email>john@example.com</email>
|
||||
</author>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertEquals("John Doe", entry.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithCategories() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Categorized Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<entry>
|
||||
<title>Categorized Entry</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<category term="technology"/>
|
||||
<category term="programming"/>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertEquals(2, entry.categories?.size)
|
||||
assertEquals("technology", entry.categories?.get(0))
|
||||
assertEquals("programming", entry.categories?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithEnclosure() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Enclosure Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<entry>
|
||||
<title>Episode</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<link href="https://example.com/ep.mp3" rel="enclosure" type="audio/mpeg" length="12345678"/>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertNotNull(entry.enclosure)
|
||||
assertEquals("https://example.com/ep.mp3", entry.enclosure?.url)
|
||||
assertEquals("audio/mpeg", entry.enclosure?.type)
|
||||
assertEquals(12345678L, entry.enclosure?.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithContent() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Content Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<entry>
|
||||
<title>Entry</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<summary>Short summary</summary>
|
||||
<content>Full HTML content</content>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertEquals("Full HTML content", entry.content)
|
||||
assertEquals("Short summary", entry.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithiTunesExtension() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<title>Podcast</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<entry>
|
||||
<title>Episode</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<itunes:duration>3600</itunes:duration>
|
||||
<itunes:summary>Episode summary</itunes:summary>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertEquals("Episode summary", entry.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithPublished() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Date Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
<updated>2024-06-15T12:00:00Z</updated>
|
||||
<entry>
|
||||
<title>Entry</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
<published>2024-01-01T08:00:00Z</published>
|
||||
<updated>2024-01-02T10:00:00Z</updated>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
val entry = feed.items[0]
|
||||
assertNotNull(entry.published)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithEmptyFeed() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Empty Feed</title>
|
||||
<id>urn:uuid:feed-id</id>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Empty Feed", feed.title)
|
||||
assertEquals(0, feed.items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomWithMissingFields() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>Minimal Entry</title>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Untitled Feed", feed.title)
|
||||
assertEquals(1, feed.items.size)
|
||||
assertEquals("Minimal Entry", feed.items[0].title)
|
||||
assertNull(feed.items[0].link)
|
||||
}
|
||||
}
|
||||
162
android/src/test/java/com/rssuper/parsing/FeedParserTest.kt
Normal file
162
android/src/test/java/com/rssuper/parsing/FeedParserTest.kt
Normal file
@@ -0,0 +1,162 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [24])
|
||||
class FeedParserTest {
|
||||
|
||||
@Test
|
||||
fun testParseRSSFeed() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>RSS Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<item>
|
||||
<title>Item</title>
|
||||
<link>https://example.com/item</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val result = FeedParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(FeedType.RSS, result.feedType)
|
||||
assertEquals("RSS Feed", result.feed.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseAtomFeed() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom Feed</title>
|
||||
<id>urn:uuid:feed</id>
|
||||
<entry>
|
||||
<title>Entry</title>
|
||||
<id>urn:uuid:entry</id>
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent()
|
||||
|
||||
val result = FeedParser.parse(xml, "https://example.com/feed.atom")
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(FeedType.Atom, result.feedType)
|
||||
assertEquals("Atom Feed", result.feed.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithNamespaces() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>Namespaced Feed</title>
|
||||
<atom:link href="https://example.com/feed.xml" rel="self"/>
|
||||
<itunes:author>Author</itunes:author>
|
||||
<item>
|
||||
<title>Item</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val result = FeedParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(FeedType.RSS, result.feedType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseMalformedXml() {
|
||||
val malformedXml = """
|
||||
<?xml version="1.0"?>
|
||||
<rss>
|
||||
<channel>
|
||||
<title>Broken
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
val result = FeedParser.parse(malformedXml, "https://example.com/feed.xml")
|
||||
assertNotNull(result)
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseInvalidFeedType() {
|
||||
val invalidXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<invalid>
|
||||
<data>Some data</data>
|
||||
</invalid>
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
FeedParser.parse(invalidXml, "https://example.com/feed.xml")
|
||||
fail("Expected exception for invalid feed type")
|
||||
} catch (e: FeedParsingError) {
|
||||
assertEquals(FeedParsingError.UnsupportedFeedType, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseEmptyFeed() {
|
||||
val emptyXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title></title>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val result = FeedParser.parse(emptyXml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Untitled Feed", result.feed.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAsyncCallback() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Async Feed</title>
|
||||
<item>
|
||||
<title>Item</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
FeedParser.parseAsync(xml, "https://example.com/feed.xml") { result ->
|
||||
assert(result.isSuccess)
|
||||
val feed = result.getOrNull()
|
||||
assertNotNull(feed)
|
||||
assertEquals("Async Feed", feed?.feed?.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAsyncCallbackError() {
|
||||
val invalidXml = "not xml"
|
||||
|
||||
FeedParser.parseAsync(invalidXml, "https://example.com/feed.xml") { result ->
|
||||
assert(result.isFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
255
android/src/test/java/com/rssuper/parsing/RSSParserTest.kt
Normal file
255
android/src/test/java/com/rssuper/parsing/RSSParserTest.kt
Normal file
@@ -0,0 +1,255 @@
|
||||
package com.rssuper.parsing
|
||||
|
||||
import com.rssuper.models.Enclosure
|
||||
import com.rssuper.models.Feed
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [24])
|
||||
class RSSParserTest {
|
||||
|
||||
@Test
|
||||
fun testParseBasicRSS() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A test feed</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>Mon, 01 Jan 2024 12:00:00 GMT</lastBuildDate>
|
||||
<generator>RSS Generator</generator>
|
||||
<ttl>60</ttl>
|
||||
<item>
|
||||
<title>Item 1</title>
|
||||
<link>https://example.com/item1</link>
|
||||
<description>Description of item 1</description>
|
||||
<guid isPermaLink="true">https://example.com/item1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item 2</title>
|
||||
<link>https://example.com/item2</link>
|
||||
<description>Description of item 2</description>
|
||||
<guid>item-2-guid</guid>
|
||||
<pubDate>Sun, 31 Dec 2023 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Test Feed", feed.title)
|
||||
assertEquals("https://example.com", feed.link)
|
||||
assertEquals("A test feed", feed.description)
|
||||
assertEquals("en-us", feed.language)
|
||||
assertEquals(60, feed.ttl)
|
||||
assertEquals(2, feed.items.size)
|
||||
|
||||
val item1 = feed.items[0]
|
||||
assertEquals("Item 1", item1.title)
|
||||
assertEquals("https://example.com/item1", item1.link)
|
||||
assertEquals("Description of item 1", item1.description)
|
||||
assertNotNull(item1.published)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithiTunesNamespace() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>Podcast Feed</title>
|
||||
<link>https://example.com/podcast</link>
|
||||
<description>My podcast</description>
|
||||
<itunes:subtitle>Podcast subtitle</itunes:subtitle>
|
||||
<itunes:author>Author Name</itunes:author>
|
||||
<item>
|
||||
<title>Episode 1</title>
|
||||
<link>https://example.com/episode1</link>
|
||||
<description>Episode description</description>
|
||||
<itunes:duration>01:30:00</itunes:duration>
|
||||
<enclosure url="https://example.com/ep1.mp3" type="audio/mpeg" length="12345678"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Podcast Feed", feed.title)
|
||||
|
||||
val item = feed.items[0]
|
||||
assertEquals("Episode 1", item.title)
|
||||
assertNotNull(item.enclosure)
|
||||
assertEquals("https://example.com/ep1.mp3", item.enclosure?.url)
|
||||
assertEquals("audio/mpeg", item.enclosure?.type)
|
||||
assertEquals(12345678L, item.enclosure?.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithContentNamespace() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>Feed with Content</title>
|
||||
<item>
|
||||
<title>Item with Content</title>
|
||||
<description>Short description</description>
|
||||
<content:encoded><![CDATA[<p>Full content here</p>]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals(1, feed.items.size)
|
||||
assertEquals("Item with Content", feed.items[0].title)
|
||||
assertEquals("<p>Full content here</p>", feed.items[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithCategories() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Categorized Feed</title>
|
||||
<item>
|
||||
<title>Tech Article</title>
|
||||
<category>Technology</category>
|
||||
<category>Programming</category>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
val item = feed.items[0]
|
||||
assertEquals(2, item.categories?.size)
|
||||
assertEquals("Technology", item.categories?.get(0))
|
||||
assertEquals("Programming", item.categories?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithAuthor() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Author Feed</title>
|
||||
<item>
|
||||
<title>Article by Author</title>
|
||||
<author>author@example.com (John Doe)</author>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
val item = feed.items[0]
|
||||
assertEquals("author@example.com (John Doe)", item.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithGuid() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Guid Feed</title>
|
||||
<item>
|
||||
<title>Item</title>
|
||||
<guid>custom-guid-12345</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("custom-guid-12345", feed.items[0].guid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithEmptyChannel() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Minimal Feed</title>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Minimal Feed", feed.title)
|
||||
assertEquals(0, feed.items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithMissingFields() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Only Title</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("Untitled Feed", feed.title)
|
||||
assertEquals(1, feed.items.size)
|
||||
assertEquals("Only Title", feed.items[0].title)
|
||||
assertNull(feed.items[0].link)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRSSWithCDATA() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title><![CDATA[CDATA Title]]></title>
|
||||
<description><![CDATA[<p>HTML <strong>content</strong></p>]]></description>
|
||||
<item>
|
||||
<title>CDATA Item</title>
|
||||
<description><![CDATA[Item content]]></description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
""".trimIndent()
|
||||
|
||||
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||
|
||||
assertNotNull(feed)
|
||||
assertEquals("CDATA Title", feed.title)
|
||||
assertEquals("<p>HTML <strong>content</strong></p>", feed.description)
|
||||
assertEquals("Item content", feed.items[0].description)
|
||||
}
|
||||
}
|
||||
@@ -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,106 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FeedFetcherIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun testFetchRealFeed() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchAndParseRealFeed() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetchAndParse("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithHTTPAuthCredentials() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val auth = HTTPAuthCredentials("testuser", "testpass")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithCacheControl() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchPerformance() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
assertTrue(duration < 20000 || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithIfNoneMatch() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val etag = "test-etag-value"
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithIfModifiedSince() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchMultipleFeeds() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val urls = listOf(
|
||||
"https://example.com/feed1.xml",
|
||||
"https://example.com/feed2.xml"
|
||||
)
|
||||
|
||||
for (url in urls) {
|
||||
val result = feedFetcher.fetch(url)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithDifferentTimeouts() {
|
||||
val shortTimeoutFetcher = FeedFetcher(timeoutMs = 1000)
|
||||
val longTimeoutFetcher = FeedFetcher(timeoutMs = 30000)
|
||||
|
||||
val shortClientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
shortClientField.isAccessible = true
|
||||
val shortClient = shortClientField.get(shortTimeoutFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
val longClientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
longClientField.isAccessible = true
|
||||
val longClient = longClientField.get(longTimeoutFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
assertTrue(shortClient.connectTimeoutMillis < longClient.connectTimeoutMillis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FeedFetcherTest {
|
||||
|
||||
@Test
|
||||
fun testOkHttpConfiguration() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 5000)
|
||||
val clientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
clientField.isAccessible = true
|
||||
val okHttpClient = clientField.get(feedFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
assertEquals(5000, okHttpClient.connectTimeoutMillis)
|
||||
assertEquals(5000, okHttpClient.readTimeoutMillis)
|
||||
assertEquals(5000, okHttpClient.writeTimeoutMillis)
|
||||
assertNotNull(okHttpClient.eventListenerFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithHTTPAuth() {
|
||||
val auth = HTTPAuthCredentials("user", "pass")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithETag() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
val etag = "test-etag-123"
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithLastModified() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchRetrySuccess() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000, maxRetries = 3)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FetchResultTest {
|
||||
|
||||
@Test
|
||||
fun testFetchResultCreation() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false
|
||||
)
|
||||
|
||||
assertEquals("<rss>test</rss>", result.feedXml)
|
||||
assertEquals("https://example.com/feed.xml", result.url)
|
||||
assertEquals(false, result.isCached)
|
||||
assertEquals(null, result.cacheControl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithETag() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false,
|
||||
etag = "test-etag-123"
|
||||
)
|
||||
|
||||
assertEquals("test-etag-123", result.etag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithLastModified() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false,
|
||||
lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
)
|
||||
|
||||
assertEquals("Mon, 01 Jan 2024 00:00:00 GMT", result.lastModified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultIsCached() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = true
|
||||
)
|
||||
|
||||
assertTrue(result.isCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithCacheControl() {
|
||||
val cacheControl = okhttp3.CacheControl.Builder()
|
||||
.noCache()
|
||||
.build()
|
||||
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = cacheControl,
|
||||
isCached = false
|
||||
)
|
||||
|
||||
assertNotNull(result.cacheControl)
|
||||
assertTrue(result.cacheControl!!.noCache)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HTTPAuthCredentialsTest {
|
||||
|
||||
@Test
|
||||
fun testBasicAuthCredentials() {
|
||||
val auth = HTTPAuthCredentials("username", "password")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBasicAuthCredentialsWithSpecialChars() {
|
||||
val auth = HTTPAuthCredentials("user@domain", "pass!@#")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUsernameAndPassword() {
|
||||
val auth = HTTPAuthCredentials("testuser", "testpass")
|
||||
|
||||
assertEquals("testuser", auth.username)
|
||||
assertEquals("testpass", auth.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyUsername() {
|
||||
val auth = HTTPAuthCredentials("", "password")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyPassword() {
|
||||
val auth = HTTPAuthCredentials("username", "")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
}
|
||||
66
android/src/test/java/com/rssuper/state/StateTest.kt
Normal file
66
android/src/test/java/com/rssuper/state/StateTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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