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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user