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

This commit is contained in:
2026-03-30 16:39:18 -04:00
parent a8e07d52f0
commit c2e1622bd8
252 changed files with 4803 additions and 17165 deletions

View File

@@ -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
}
}

View File

@@ -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> }
}
}

View File

@@ -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() }
}
}

View File

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

View File

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

View File

@@ -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>
}

View File

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

View File

@@ -0,0 +1,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
}

View File

@@ -0,0 +1,43 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.Date
@Entity(
tableName = "bookmarks",
indices = [Index(value = ["feedItemId"], unique = true)]
)
data class BookmarkEntity(
@PrimaryKey
val id: String,
val feedItemId: String,
val title: String,
val link: String? = null,
val description: String? = null,
val content: String? = null,
val createdAt: Date,
val tags: String? = null
) {
fun toFeedItem(): FeedItemEntity {
return FeedItemEntity(
id = feedItemId,
subscriptionId = "", // Will be set when linked to subscription
title = title,
link = link,
description = description,
content = content,
published = createdAt,
updated = createdAt
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View 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

View 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

View File

@@ -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

View File

@@ -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")
}

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

View 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
}

View 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
)
}
}

View 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
}
}

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

View 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()
}

View 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
)
}
}

View 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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&#x27;", "'")
}
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)
}
}
}

View 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
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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