restructure
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

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

2
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.gradle
build

74
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,74 @@
plugins {
id("com.android.library") version "8.5.0"
id("org.jetbrains.kotlin.android") version "1.9.22"
id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.22"
id("org.jetbrains.kotlin.kapt") version "1.9.22"
}
android {
namespace = "com.rssuper"
compileSdk = 34
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/java")
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
// XML Parsing - built-in XmlPullParser
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Moshi for JSON serialization
implementation("com.squareup.moshi:moshi:1.15.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
// OkHttp for networking
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.moshi:moshi:1.15.0")
testImplementation("com.squareup.moshi:moshi-kotlin:1.15.0")
testImplementation("org.mockito:mockito-core:5.7.0")
testImplementation("org.mockito:mockito-inline:5.2.0")
testImplementation("androidx.room:room-testing:2.6.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("androidx.test:runner:1.5.2")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
kapt.use.worker.api=false
android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

170
android/gradlew vendored Executable file
View File

@@ -0,0 +1,170 @@
#!/bin/sh
#
# Copyright 2015-2021 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# temporary options; we will parse these below.
# * There is no need to specify -classpath explicitly.
# * Gradle's Java options need to be preprocessed to be merged.
# * We use eval to parse quoted options properly.
# Collect arguments from the command line
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n://services.gradle.org/distributions/gradle-8.2-bin.zip
# In either case, if the arg is not present, we don't add it.
# If the arg is present but empty, we add it as empty string.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "RSSuper"
include(":android")

View File

@@ -0,0 +1,16 @@
package com.rssuper.converters
import androidx.room.TypeConverter
import java.util.Date
class DateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}

View File

@@ -0,0 +1,23 @@
package com.rssuper.converters
import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.rssuper.models.FeedItem
class FeedItemListConverter {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val adapter = moshi.adapter(List::class.java)
@TypeConverter
fun fromFeedItemList(value: List<FeedItem>?): String? {
return value?.let { adapter.toJson(it) }
}
@TypeConverter
fun toFeedItemList(value: String?): List<FeedItem>? {
return value?.let { adapter.fromJson(it) as? List<FeedItem> }
}
}

View File

@@ -0,0 +1,15 @@
package com.rssuper.converters
import androidx.room.TypeConverter
class StringListConverter {
@TypeConverter
fun fromStringList(value: List<String>?): String? {
return value?.joinToString(",")
}
@TypeConverter
fun toStringList(value: String?): List<String>? {
return value?.split(",")?.filter { it.isNotEmpty() }
}
}

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
package com.rssuper.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.rssuper.database.entities.FeedItemEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface FeedItemDao {
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC")
fun getItemsBySubscription(subscriptionId: String): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE id = :id")
suspend fun getItemById(id: String): FeedItemEntity?
@Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC")
fun getItemsBySubscriptions(subscriptionIds: List<String>): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
fun getUnreadItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
fun getStarredItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
fun getUnreadCount(subscriptionId: String): Flow<Int>
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
fun getTotalUnreadCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: FeedItemEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
@Update
suspend fun updateItem(item: FeedItemEntity): Int
@Delete
suspend fun deleteItem(item: FeedItemEntity): Int
@Query("DELETE FROM feed_items WHERE id = :id")
suspend fun deleteItemById(id: String): Int
@Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId")
suspend fun deleteItemsBySubscription(subscriptionId: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE id = :id")
suspend fun markAsRead(id: String): Int
@Query("UPDATE feed_items SET isRead = 0 WHERE id = :id")
suspend fun markAsUnread(id: String): Int
@Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id")
suspend fun markAsStarred(id: String): Int
@Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id")
suspend fun markAsUnstarred(id: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId")
suspend fun markAllAsRead(subscriptionId: String): Int
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit")
suspend fun searchByFts(query: String, limit: Int = 20): List<FeedItemEntity>
}

View File

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

View File

@@ -0,0 +1,65 @@
package com.rssuper.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY title ASC")
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE id = :id")
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE url = :url")
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC")
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
@Update
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
@Delete
suspend fun deleteSubscription(subscription: SubscriptionEntity): Int
@Query("DELETE FROM subscriptions WHERE id = :id")
suspend fun deleteSubscriptionById(id: String): Int
@Query("SELECT COUNT(*) FROM subscriptions")
fun getSubscriptionCount(): Flow<Int>
@Query("UPDATE subscriptions SET error = :error WHERE id = :id")
suspend fun updateError(id: String, error: String?)
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date)
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
@Query("UPDATE subscriptions SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean): Int
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
suspend fun updateLastFetchedAtMillis(id: String, lastFetchedAt: Long): Int
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAtMillis(id: String, nextFetchAt: Long): Int
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.FeedItemListConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, FeedItemListConverter::class)
@Entity(tableName = "feeds")
data class Feed(
@PrimaryKey
val id: String,
@Json(name = "title")
val title: String,
@Json(name = "link")
val link: String? = null,
@Json(name = "description")
val description: String? = null,
@Json(name = "subtitle")
val subtitle: String? = null,
@Json(name = "language")
val language: String? = null,
@Json(name = "lastBuildDate")
val lastBuildDate: Date? = null,
@Json(name = "updated")
val updated: Date? = null,
@Json(name = "generator")
val generator: String? = null,
@Json(name = "ttl")
val ttl: Int? = null,
@Json(name = "items")
val items: List<FeedItem> = emptyList(),
@Json(name = "rawUrl")
val rawUrl: String,
@Json(name = "lastFetchedAt")
val lastFetchedAt: Date? = null,
@Json(name = "nextFetchAt")
val nextFetchAt: Date? = null
) : Parcelable

View File

@@ -0,0 +1,67 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.StringListConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, StringListConverter::class)
@Entity(tableName = "feed_items")
data class FeedItem(
@PrimaryKey
val id: String,
@Json(name = "title")
val title: String,
@Json(name = "link")
val link: String? = null,
@Json(name = "description")
val description: String? = null,
@Json(name = "content")
val content: String? = null,
@Json(name = "author")
val author: String? = null,
@Json(name = "published")
val published: Date? = null,
@Json(name = "updated")
val updated: Date? = null,
@Json(name = "categories")
val categories: List<String>? = null,
@Json(name = "enclosure")
val enclosure: Enclosure? = null,
@Json(name = "guid")
val guid: String? = null,
@Json(name = "subscriptionTitle")
val subscriptionTitle: String? = null
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class Enclosure(
@Json(name = "url")
val url: String,
@Json(name = "type")
val type: String,
@Json(name = "length")
val length: Long? = null
) : Parcelable

View File

@@ -0,0 +1,63 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class)
@Entity(tableName = "feed_subscriptions")
data class FeedSubscription(
@PrimaryKey
val id: String,
@Json(name = "url")
val url: String,
@Json(name = "title")
val title: String,
@Json(name = "category")
val category: String? = null,
@Json(name = "enabled")
val enabled: Boolean = true,
@Json(name = "fetchInterval")
val fetchInterval: Long,
@Json(name = "createdAt")
val createdAt: Date,
@Json(name = "updatedAt")
val updatedAt: Date,
@Json(name = "lastFetchedAt")
val lastFetchedAt: Date? = null,
@Json(name = "nextFetchAt")
val nextFetchAt: Date? = null,
@Json(name = "error")
val error: String? = null,
@Json(name = "httpAuth")
val httpAuth: HttpAuth? = null
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class HttpAuth(
@Json(name = "username")
val username: String,
@Json(name = "password")
val password: String
) : Parcelable

View File

@@ -0,0 +1,34 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Parcelize
@Entity(tableName = "notification_preferences")
data class NotificationPreferences(
@PrimaryKey
val id: String = "default",
@Json(name = "newArticles")
val newArticles: Boolean = true,
@Json(name = "episodeReleases")
val episodeReleases: Boolean = true,
@Json(name = "customAlerts")
val customAlerts: Boolean = false,
@Json(name = "badgeCount")
val badgeCount: Boolean = true,
@Json(name = "sound")
val sound: Boolean = true,
@Json(name = "vibration")
val vibration: Boolean = true
) : Parcelable

View File

@@ -0,0 +1,60 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Parcelize
@Entity(tableName = "reading_preferences")
data class ReadingPreferences(
@PrimaryKey
val id: String = "default",
@Json(name = "fontSize")
val fontSize: @RawValue FontSize = FontSize.MEDIUM,
@Json(name = "lineHeight")
val lineHeight: @RawValue LineHeight = LineHeight.NORMAL,
@Json(name = "showTableOfContents")
val showTableOfContents: Boolean = false,
@Json(name = "showReadingTime")
val showReadingTime: Boolean = true,
@Json(name = "showAuthor")
val showAuthor: Boolean = true,
@Json(name = "showDate")
val showDate: Boolean = true
) : Parcelable
sealed class FontSize(val value: String) {
@Json(name = "small")
data object SMALL : FontSize("small")
@Json(name = "medium")
data object MEDIUM : FontSize("medium")
@Json(name = "large")
data object LARGE : FontSize("large")
@Json(name = "xlarge")
data object XLARGE : FontSize("xlarge")
}
sealed class LineHeight(val value: String) {
@Json(name = "normal")
data object NORMAL : LineHeight("normal")
@Json(name = "relaxed")
data object RELAXED : LineHeight("relaxed")
@Json(name = "loose")
data object LOOSE : LineHeight("loose")
}

View File

@@ -0,0 +1,74 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.StringListConverter
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, StringListConverter::class)
@Entity(tableName = "search_filters")
data class SearchFilters(
@PrimaryKey
val id: String = "default",
@Json(name = "dateFrom")
val dateFrom: Date? = null,
@Json(name = "dateTo")
val dateTo: Date? = null,
@Json(name = "feedIds")
val feedIds: List<String>? = null,
@Json(name = "authors")
val authors: List<String>? = null,
@Json(name = "contentType")
val contentType: @RawValue ContentType? = null,
@Json(name = "sortOption")
val sortOption: @RawValue SearchSortOption = SearchSortOption.RELEVANCE
) : Parcelable
sealed class ContentType(val value: String) {
@Json(name = "article")
data object ARTICLE : ContentType("article")
@Json(name = "audio")
data object AUDIO : ContentType("audio")
@Json(name = "video")
data object VIDEO : ContentType("video")
}
sealed class SearchSortOption(val value: String) {
@Json(name = "relevance")
data object RELEVANCE : SearchSortOption("relevance")
@Json(name = "date_desc")
data object DATE_DESC : SearchSortOption("date_desc")
@Json(name = "date_asc")
data object DATE_ASC : SearchSortOption("date_asc")
@Json(name = "title_asc")
data object TITLE_ASC : SearchSortOption("title_asc")
@Json(name = "title_desc")
data object TITLE_DESC : SearchSortOption("title_desc")
@Json(name = "feed_asc")
data object FEED_ASC : SearchSortOption("feed_asc")
@Json(name = "feed_desc")
data object FEED_DESC : SearchSortOption("feed_desc")
}

View File

@@ -0,0 +1,49 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class)
@Entity(tableName = "search_results")
data class SearchResult(
@PrimaryKey
val id: String,
@Json(name = "type")
val type: SearchResultType,
@Json(name = "title")
val title: String,
@Json(name = "snippet")
val snippet: String? = null,
@Json(name = "link")
val link: String? = null,
@Json(name = "feedTitle")
val feedTitle: String? = null,
@Json(name = "published")
val published: Date? = null,
@Json(name = "score")
val score: Double? = null
) : Parcelable
enum class SearchResultType {
@Json(name = "article")
ARTICLE,
@Json(name = "feed")
FEED
}

View File

@@ -0,0 +1,240 @@
package com.rssuper.parsing
import com.rssuper.models.Enclosure
import com.rssuper.models.Feed
import com.rssuper.models.FeedItem
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader
object AtomParser {
private val ATOM_NS = "http://www.w3.org/2005/Atom"
private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
private val MEDIA_NS = "http://search.yahoo.com/mrss/"
fun parse(xml: String, feedUrl: String): Feed {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(StringReader(xml))
var title: String? = null
var link: String? = null
var subtitle: String? = null
var updated: java.util.Date? = null
var generator: String? = null
val items = mutableListOf<FeedItem>()
var currentItem: MutableMap<String, Any?>? = null
var currentTag: String? = null
var inContent = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val tagName = parser.name
val namespace = parser.namespace
when {
tagName == "feed" -> {}
tagName == "entry" -> {
currentItem = mutableMapOf()
}
tagName == "title" -> {
currentTag = tagName
inContent = true
}
tagName == "link" -> {
val href = parser.getAttributeValue(null, "href")
val rel = parser.getAttributeValue(null, "rel")
if (href != null) {
if (currentItem != null) {
if (rel == "alternate" || rel == null) {
currentItem["link"] = href
} else if (rel == "enclosure") {
val type = parser.getAttributeValue(null, "type") ?: "application/octet-stream"
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
currentItem["enclosure"] = Enclosure(href, type, length)
}
} else {
if (rel == "alternate" || rel == null) {
link = href
}
}
}
currentTag = null
inContent = false
}
tagName == "subtitle" -> {
currentTag = tagName
inContent = true
}
tagName == "summary" -> {
currentTag = tagName
inContent = true
}
tagName == "content" -> {
currentTag = tagName
inContent = true
}
tagName == "updated" || tagName == "published" -> {
currentTag = tagName
inContent = true
}
tagName == "name" -> {
currentTag = tagName
inContent = true
}
tagName == "uri" -> {
currentTag = tagName
inContent = true
}
tagName == "id" -> {
currentTag = tagName
inContent = true
}
tagName == "category" -> {
val term = parser.getAttributeValue(null, "term")
if (term != null && currentItem != null) {
val cats = currentItem["categories"] as? MutableList<String> ?: mutableListOf()
cats.add(term)
currentItem["categories"] = cats
}
currentTag = null
inContent = false
}
tagName == "generator" -> {
currentTag = tagName
inContent = true
}
tagName == "summary" && namespace == ITUNES_NS -> {
if (currentItem != null) {
currentItem["itunesSummary"] = readElementText(parser)
}
}
tagName == "image" && namespace == ITUNES_NS -> {
val href = parser.getAttributeValue(null, "href")
if (href != null && currentItem != null) {
currentItem["image"] = href
}
}
tagName == "duration" && namespace == ITUNES_NS -> {
currentItem?.put("duration", readElementText(parser))
}
tagName == "thumbnail" && namespace == MEDIA_NS -> {
val url = parser.getAttributeValue(null, "url")
if (url != null && currentItem != null) {
currentItem["mediaThumbnail"] = url
}
}
tagName == "enclosure" && namespace == MEDIA_NS -> {
val url = parser.getAttributeValue(null, "url")
val type = parser.getAttributeValue(null, "type")
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
if (url != null && type != null && currentItem != null) {
currentItem["enclosure"] = Enclosure(url, type, length)
}
}
else -> {}
}
}
XmlPullParser.TEXT -> {
val text = parser.text?.xmlTrimmed() ?: ""
if (text.isNotEmpty() && inContent) {
if (currentItem != null) {
when (currentTag) {
"title" -> currentItem["title"] = text
"summary" -> currentItem["summary"] = text
"content" -> currentItem["content"] = text
"name" -> currentItem["author"] = text
"id" -> currentItem["guid"] = text
"updated", "published" -> currentItem[currentTag] = text
}
} else {
when (currentTag) {
"title" -> title = text
"subtitle" -> subtitle = text
"id" -> if (title == null) title = text
"updated" -> updated = XmlDateParser.parse(text)
"generator" -> generator = text
}
}
}
}
XmlPullParser.END_TAG -> {
val tagName = parser.name
if (tagName == "entry" && currentItem != null) {
items.add(buildFeedItem(currentItem))
currentItem = null
}
if (tagName == currentTag) {
currentTag = null
inContent = false
}
}
}
eventType = parser.next()
}
return Feed(
id = generateUuid(),
title = title ?: "Untitled Feed",
link = link,
subtitle = subtitle,
description = subtitle,
updated = updated,
generator = generator,
items = items,
rawUrl = feedUrl,
lastFetchedAt = java.util.Date()
)
}
private fun readElementText(parser: XmlPullParser): String {
var text = ""
var eventType = parser.eventType
while (eventType != XmlPullParser.END_TAG) {
if (eventType == XmlPullParser.TEXT) {
text = parser.text.xmlDecoded()
}
eventType = parser.next()
}
return text.xmlTrimmed()
}
@Suppress("UNCHECKED_CAST")
private fun buildFeedItem(item: Map<String, Any?>): FeedItem {
val title = item["title"] as? String ?: "Untitled"
val link = item["link"] as? String
val summary = item["summary"] as? String
val content = item["content"] as? String ?: summary
val itunesSummary = item["itunesSummary"] as? String
val author = item["author"] as? String
val guid = item["guid"] as? String ?: link ?: generateUuid()
val categories = item["categories"] as? List<String>
val enclosure = item["enclosure"] as? Enclosure
val updatedStr = item["updated"] as? String
val publishedStr = item["published"] as? String
val published = XmlDateParser.parse(publishedStr ?: updatedStr)
val updated = XmlDateParser.parse(updatedStr)
return FeedItem(
id = generateUuid(),
title = title,
link = link,
description = summary ?: itunesSummary,
content = content,
author = author,
published = published,
updated = updated,
categories = categories,
enclosure = enclosure,
guid = guid
)
}
}

View File

@@ -0,0 +1,67 @@
package com.rssuper.parsing
import com.rssuper.models.Feed
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader
import java.util.Date
object FeedParser {
fun parse(xml: String, feedUrl: String): ParseResult {
val feedType = detectFeedType(xml)
return when (feedType) {
FeedType.RSS -> {
val feed = RSSParser.parse(xml, feedUrl)
ParseResult(FeedType.RSS, feed)
}
FeedType.Atom -> {
val feed = AtomParser.parse(xml, feedUrl)
ParseResult(FeedType.Atom, feed)
}
}
}
fun parseAsync(xml: String, feedUrl: String, callback: (Result<ParseResult>) -> Unit) {
try {
val result = parse(xml, feedUrl)
callback(Result.success(result))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
private fun detectFeedType(xml: String): FeedType {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(StringReader(xml))
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
val tagName = parser.name
return when {
tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
tagName.equals("RDF", ignoreCase = true) -> FeedType.RSS
else -> {
val namespace = parser.namespace
if (namespace != null && namespace.isNotEmpty()) {
when {
tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
else -> throw FeedParsingError.UnsupportedFeedType
}
} else {
throw FeedParsingError.UnsupportedFeedType
}
}
}
}
eventType = parser.next()
}
throw FeedParsingError.UnsupportedFeedType
}
}

View File

@@ -0,0 +1,16 @@
package com.rssuper.parsing
sealed class FeedType(val value: String) {
data object RSS : FeedType("rss")
data object Atom : FeedType("atom")
companion object {
fun fromString(value: String): FeedType {
return when (value.lowercase()) {
"rss" -> RSS
"atom" -> Atom
else -> throw IllegalArgumentException("Unknown feed type: $value")
}
}
}
}

View File

@@ -0,0 +1,13 @@
package com.rssuper.parsing
import com.rssuper.models.Feed
data class ParseResult(
val feedType: FeedType,
val feed: Feed
)
sealed class FeedParsingError : Exception() {
data object UnsupportedFeedType : FeedParsingError()
data object MalformedXml : FeedParsingError()
}

View File

@@ -0,0 +1,188 @@
package com.rssuper.parsing
import com.rssuper.models.Enclosure
import com.rssuper.models.Feed
import com.rssuper.models.FeedItem
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader
import java.util.Date
object RSSParser {
private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
private val CONTENT_NS = "http://purl.org/rss/1.0/modules/content/"
fun parse(xml: String, feedUrl: String): Feed {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(StringReader(xml))
var title: String? = null
var link: String? = null
var description: String? = null
var language: String? = null
var lastBuildDate: Date? = null
var generator: String? = null
var ttl: Int? = null
val items = mutableListOf<FeedItem>()
var currentItem: MutableMap<String, Any?>? = null
var currentTag: String? = null
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val tagName = parser.name
val namespace = parser.namespace
when {
tagName == "channel" -> {}
tagName == "item" -> {
currentItem = mutableMapOf()
}
tagName == "title" || tagName == "description" ||
tagName == "link" || tagName == "author" ||
tagName == "guid" || tagName == "pubDate" ||
tagName == "category" || tagName == "enclosure" -> {
currentTag = tagName
}
tagName == "language" -> currentTag = tagName
tagName == "lastBuildDate" -> currentTag = tagName
tagName == "generator" -> currentTag = tagName
tagName == "ttl" -> currentTag = tagName
tagName == "subtitle" && namespace == ITUNES_NS -> {
if (currentItem == null) {
description = readElementText(parser)
}
}
tagName == "summary" && namespace == ITUNES_NS -> {
currentItem?.put("description", readElementText(parser))
}
tagName == "duration" && namespace == ITUNES_NS -> {
currentItem?.put("duration", readElementText(parser))
}
tagName == "image" && namespace == ITUNES_NS -> {
val href = parser.getAttributeValue(null, "href")
if (href != null && currentItem != null) {
currentItem.put("image", href)
}
}
tagName == "encoded" && namespace == CONTENT_NS -> {
currentItem?.put("content", readElementText(parser))
}
else -> {}
}
if (tagName == "enclosure" && currentItem != null) {
val url = parser.getAttributeValue(null, "url")
val type = parser.getAttributeValue(null, "type")
val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
if (url != null && type != null) {
currentItem["enclosure"] = Enclosure(url, type, length)
}
}
}
XmlPullParser.TEXT -> {
val text = parser.text?.xmlTrimmed() ?: ""
if (text.isNotEmpty()) {
if (currentItem != null) {
when (currentTag) {
"title" -> currentItem["title"] = text
"description" -> currentItem["description"] = text
"link" -> currentItem["link"] = text
"author" -> currentItem["author"] = text
"guid" -> currentItem["guid"] = text
"pubDate" -> currentItem["pubDate"] = text
"category" -> {
val cats = currentItem["categories"] as? MutableList<String> ?: mutableListOf()
cats.add(text)
currentItem["categories"] = cats
}
}
} else {
when (currentTag) {
"title" -> title = text
"link" -> link = text
"description" -> description = text
"language" -> language = text
"lastBuildDate" -> lastBuildDate = XmlDateParser.parse(text)
"generator" -> generator = text
"ttl" -> ttl = text.toIntOrNull()
}
}
}
}
XmlPullParser.END_TAG -> {
val tagName = parser.name
if (tagName == "item" && currentItem != null) {
items.add(buildFeedItem(currentItem))
currentItem = null
}
currentTag = null
}
}
eventType = parser.next()
}
return Feed(
id = generateUuid(),
title = title ?: "Untitled Feed",
link = link,
description = description,
language = language,
lastBuildDate = lastBuildDate,
generator = generator,
ttl = ttl,
items = items,
rawUrl = feedUrl,
lastFetchedAt = Date()
)
}
private fun readElementText(parser: XmlPullParser): String {
var text = ""
var eventType = parser.eventType
while (eventType != XmlPullParser.END_TAG) {
if (eventType == XmlPullParser.TEXT) {
text = parser.text.xmlDecoded()
}
eventType = parser.next()
}
return text.xmlTrimmed()
}
@Suppress("UNCHECKED_CAST")
private fun buildFeedItem(item: Map<String, Any?>): FeedItem {
val title = item["title"] as? String ?: "Untitled"
val link = item["link"] as? String
val description = item["description"] as? String
val content = item["content"] as? String ?: description
val author = item["author"] as? String
val guid = item["guid"] as? String ?: link ?: generateUuid()
val categories = item["categories"] as? List<String>
val enclosure = item["enclosure"] as? Enclosure
val pubDateStr = item["pubDate"] as? String
val published = XmlDateParser.parse(pubDateStr)
return FeedItem(
id = generateUuid(),
title = title,
link = link,
description = description,
content = content,
author = author,
published = published,
updated = published,
categories = categories,
enclosure = enclosure,
guid = guid
)
}
}

View File

@@ -0,0 +1,154 @@
package com.rssuper.parsing
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.regex.Pattern
object XmlDateParser {
private val iso8601WithFractional: SimpleDateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
private val iso8601: SimpleDateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
private val dateFormats: List<SimpleDateFormat> by lazy {
listOf(
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US),
SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.US),
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US),
SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US),
SimpleDateFormat("yyyy-MM-dd", Locale.US)
).map {
SimpleDateFormat(it.toPattern(), Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}
fun parse(value: String?): java.util.Date? {
val trimmed = value?.xmlTrimmed() ?: return null
if (trimmed.isEmpty()) return null
return try {
iso8601WithFractional.parse(trimmed)
} catch (e: Exception) {
try {
iso8601.parse(trimmed)
} catch (e: Exception) {
for (format in dateFormats) {
try {
return format.parse(trimmed)
} catch (e: Exception) {
continue
}
}
null
}
}
}
}
fun String.xmlTrimmed(): String = this.trim { it <= ' ' }
fun String.xmlNilIfEmpty(): String? {
val trimmed = this.xmlTrimmed()
return if (trimmed.isEmpty()) null else trimmed
}
fun String.xmlDecoded(): String {
return this
.replace(Regex("<!\\[CDATA\\[", RegexOption.IGNORE_CASE), "")
.replace(Regex("\\]\\]>", RegexOption.IGNORE_CASE), "")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&#x27;", "'")
}
fun xmlInt64(value: String?): Long? {
val trimmed = value?.xmlTrimmed() ?: return null
if (trimmed.isEmpty()) return null
return trimmed.toLongOrNull()
}
fun xmlInt(value: String?): Int? {
val trimmed = value?.xmlTrimmed() ?: return null
if (trimmed.isEmpty()) return null
return trimmed.toIntOrNull()
}
fun xmlFirstTagValue(tag: String, inXml: String): String? {
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
val matcher = pattern.matcher(inXml)
return if (matcher.find()) {
matcher.group(1)?.xmlDecoded()?.xmlTrimmed()
} else {
null
}
}
fun xmlAllTagValues(tag: String, inXml: String): List<String> {
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
val matcher = pattern.matcher(inXml)
val results = mutableListOf<String>()
while (matcher.find()) {
matcher.group(1)?.xmlDecoded()?.xmlTrimmed()?.let { value ->
if (value.isNotEmpty()) {
results.add(value)
}
}
}
return results
}
fun xmlFirstBlock(tag: String, inXml: String): String? {
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
val matcher = pattern.matcher(inXml)
return if (matcher.find()) matcher.group(1) else null
}
fun xmlAllBlocks(tag: String, inXml: String): List<String> {
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
val matcher = pattern.matcher(inXml)
val results = mutableListOf<String>()
while (matcher.find()) {
matcher.group(1)?.let { results.add(it) }
}
return results
}
fun xmlAllTagAttributes(tag: String, inXml: String): List<Map<String, String>> {
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b([^>]*)/?>", Pattern.CASE_INSENSITIVE)
val matcher = pattern.matcher(inXml)
val results = mutableListOf<Map<String, String>>()
while (matcher.find()) {
matcher.group(1)?.let { results.add(parseXmlAttributes(it)) }
}
return results
}
private fun parseXmlAttributes(raw: String): Map<String, String> {
val pattern = Pattern.compile("(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"")
val matcher = pattern.matcher(raw)
val result = mutableMapOf<String, String>()
while (matcher.find()) {
val key = matcher.group(1)?.lowercase() ?: continue
val value = matcher.group(2)?.xmlDecoded()?.xmlTrimmed() ?: continue
result[key] = value
}
return result
}
fun generateUuid(): String = UUID.randomUUID().toString()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,174 @@
package com.rssuper.services
import com.rssuper.parsing.FeedParser
import com.rssuper.parsing.ParseResult
import okhttp3.Call
import okhttp3.EventListener
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
class FeedFetcher(
private val timeoutMs: Long = 15000,
private val maxRetries: Int = 3,
private val baseRetryDelayMs: Long = 1000
) {
private val client: OkHttpClient
init {
val builder = OkHttpClient.Builder()
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
builder.eventListenerFactory { call -> TimeoutEventListener(call) }
client = builder.build()
}
fun fetch(
url: String,
httpAuth: HTTPAuthCredentials? = null,
ifNoneMatch: String? = null,
ifModifiedSince: String? = null
): NetworkResult<FetchResult> {
var lastError: Throwable? = null
for (attempt in 1..maxRetries) {
val result = fetchSingleAttempt(url, httpAuth, ifNoneMatch, ifModifiedSince)
when (result) {
is NetworkResult.Success -> return result
is NetworkResult.Failure -> {
lastError = result.error
if (attempt < maxRetries) {
val delay = calculateBackoffDelay(attempt)
Thread.sleep(delay)
}
}
}
}
return NetworkResult.Failure(lastError ?: NetworkError.Unknown())
}
fun fetchAndParse(url: String, httpAuth: HTTPAuthCredentials? = null): NetworkResult<ParseResult> {
val fetchResult = fetch(url, httpAuth)
return fetchResult.flatMap { result ->
try {
val parseResult = FeedParser.parse(result.feedXml, url)
NetworkResult.Success(parseResult)
} catch (e: Exception) {
NetworkResult.Failure(NetworkError.Unknown(e))
}
}
}
private fun fetchSingleAttempt(
url: String,
httpAuth: HTTPAuthCredentials? = null,
ifNoneMatch: String? = null,
ifModifiedSince: String? = null
): NetworkResult<FetchResult> {
val requestBuilder = Request.Builder()
.url(url)
.addHeader("User-Agent", "RSSuper/1.0")
ifNoneMatch?.let { requestBuilder.addHeader("If-None-Match", it) }
ifModifiedSince?.let { requestBuilder.addHeader("If-Modified-Since", it) }
httpAuth?.let {
requestBuilder.addHeader("Authorization", it.toCredentials())
}
val request = requestBuilder.build()
return try {
val response = client.newCall(request).execute()
handleResponse(response, url)
} catch (e: IOException) {
NetworkResult.Failure(NetworkError.Unknown(e))
} catch (e: Exception) {
NetworkResult.Failure(NetworkError.Unknown(e))
}
}
private fun handleResponse(response: Response, url: String): NetworkResult<FetchResult> {
try {
val body = response.body
return when (response.code) {
200 -> {
if (body != null) {
NetworkResult.Success(FetchResult.fromResponse(response, url, response.cacheResponse != null))
} else {
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
}
}
304 -> {
if (body != null) {
NetworkResult.Success(FetchResult.fromResponse(response, url, true))
} else {
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
}
}
in 400..499 -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Client error: ${response.message}"))
}
in 500..599 -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Server error: ${response.message}"))
}
else -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Unexpected status code: ${response.code}"))
}
}
} finally {
response.close()
}
}
private fun calculateBackoffDelay(attempt: Int): Long {
var delay = baseRetryDelayMs
for (i in 1 until attempt) {
delay *= 2
}
return delay
}
private class TimeoutEventListener(private val call: Call) : EventListener() {
override fun callStart(call: Call) {
}
override fun callEnd(call: Call) {
}
override fun callFailed(call: Call, ioe: IOException) {
}
}
sealed class NetworkResult<out T> {
data class Success<T>(val value: T) : NetworkResult<T>()
data class Failure<T>(val error: Throwable) : NetworkResult<T>()
fun isSuccess(): Boolean = this is Success
fun isFailure(): Boolean = this is Failure
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
fun <R> map(transform: (T) -> R): NetworkResult<R> = when (this) {
is Success -> Success(transform(value))
is Failure -> Failure(error)
}
fun <R> flatMap(transform: (T) -> NetworkResult<R>): NetworkResult<R> = when (this) {
is Success -> transform(value)
is Failure -> Failure(error)
}
}
}

View File

@@ -0,0 +1,31 @@
package com.rssuper.services
import okhttp3.CacheControl
import okhttp3.Response
data class FetchResult(
val feedXml: String,
val url: String,
val cacheControl: CacheControl?,
val isCached: Boolean,
val etag: String? = null,
val lastModified: String? = null
) {
companion object {
fun fromResponse(response: Response, url: String, isCached: Boolean = false): FetchResult {
val body = response.body?.string() ?: ""
val cacheControl = response.cacheControl
val etag = response.header("ETag")
val lastModified = response.header("Last-Modified")
return FetchResult(
feedXml = body,
url = url,
cacheControl = cacheControl,
isCached = isCached,
etag = etag,
lastModified = lastModified
)
}
}
}

View File

@@ -0,0 +1,12 @@
package com.rssuper.services
import okhttp3.Credentials
data class HTTPAuthCredentials(
val username: String,
val password: String
) {
fun toCredentials(): String {
return Credentials.basic(username, password)
}
}

View File

@@ -0,0 +1,7 @@
package com.rssuper.services
sealed class NetworkError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
data class Http(val statusCode: Int, override val message: String) : NetworkError(message)
data class Timeout(val durationMs: Long) : NetworkError("Timeout")
data class Unknown(override val cause: Throwable? = null) : NetworkError(cause = cause)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.entities.FeedItemEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedItemDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: FeedItemDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.feedItemDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
val result = dao.getItemById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("Test Item", result?.title)
}
@Test
fun getItemsBySubscription() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub1")
val item3 = createTestItem("3", "sub2")
dao.insertItems(listOf(item1, item2, item3))
val result = dao.getItemsBySubscription("sub1").first()
assertEquals(2, result.size)
}
@Test
fun getItemsBySubscriptions() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub2")
val item3 = createTestItem("3", "sub3")
dao.insertItems(listOf(item1, item2, item3))
val result = dao.getItemsBySubscriptions(listOf("sub1", "sub2")).first()
assertEquals(2, result.size)
}
@Test
fun getUnreadItems() = runTest {
val unread = createTestItem("1", "sub1", isRead = false)
val read = createTestItem("2", "sub1", isRead = true)
dao.insertItems(listOf(unread, read))
val result = dao.getUnreadItems().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun getStarredItems() = runTest {
val starred = createTestItem("1", "sub1", isStarred = true)
val notStarred = createTestItem("2", "sub1", isStarred = false)
dao.insertItems(listOf(starred, notStarred))
val result = dao.getStarredItems().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun getItemsAfterDate() = runTest {
val oldDate = Date(System.currentTimeMillis() - 86400000 * 2)
val newDate = Date(System.currentTimeMillis() - 86400000)
val today = Date()
val oldItem = createTestItem("1", "sub1", published = oldDate)
val newItem = createTestItem("2", "sub1", published = newDate)
val todayItem = createTestItem("3", "sub1", published = today)
dao.insertItems(listOf(oldItem, newItem, todayItem))
val result = dao.getItemsAfterDate(newDate).first()
assertEquals(1, result.size)
assertEquals("3", result[0].id)
}
@Test
fun getUnreadCount() = runTest {
val unread1 = createTestItem("1", "sub1", isRead = false)
val unread2 = createTestItem("2", "sub1", isRead = false)
val read = createTestItem("3", "sub1", isRead = true)
dao.insertItems(listOf(unread1, unread2, read))
val count = dao.getUnreadCount("sub1").first()
assertEquals(2, count)
}
@Test
fun getTotalUnreadCount() = runTest {
val unread1 = createTestItem("1", "sub1", isRead = false)
val unread2 = createTestItem("2", "sub2", isRead = false)
val read = createTestItem("3", "sub1", isRead = true)
dao.insertItems(listOf(unread1, unread2, read))
val count = dao.getTotalUnreadCount().first()
assertEquals(2, count)
}
@Test
fun updateItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
val updated = item.copy(title = "Updated Title")
dao.updateItem(updated)
val result = dao.getItemById("1")
assertEquals("Updated Title", result?.title)
}
@Test
fun deleteItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
dao.deleteItem(item)
val result = dao.getItemById("1")
assertNull(result)
}
@Test
fun deleteItemById() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
dao.deleteItemById("1")
val result = dao.getItemById("1")
assertNull(result)
}
@Test
fun deleteItemsBySubscription() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub1")
val item3 = createTestItem("3", "sub2")
dao.insertItems(listOf(item1, item2, item3))
dao.deleteItemsBySubscription("sub1")
val sub1Items = dao.getItemsBySubscription("sub1").first()
val sub2Items = dao.getItemsBySubscription("sub2").first()
assertEquals(0, sub1Items.size)
assertEquals(1, sub2Items.size)
}
@Test
fun markAsRead() = runTest {
val item = createTestItem("1", "sub1", isRead = false)
dao.insertItem(item)
dao.markAsRead("1")
val result = dao.getItemById("1")
assertEquals(true, result?.isRead)
}
@Test
fun markAsUnread() = runTest {
val item = createTestItem("1", "sub1", isRead = true)
dao.insertItem(item)
dao.markAsUnread("1")
val result = dao.getItemById("1")
assertEquals(false, result?.isRead)
}
@Test
fun markAsStarred() = runTest {
val item = createTestItem("1", "sub1", isStarred = false)
dao.insertItem(item)
dao.markAsStarred("1")
val result = dao.getItemById("1")
assertEquals(true, result?.isStarred)
}
@Test
fun markAsUnstarred() = runTest {
val item = createTestItem("1", "sub1", isStarred = true)
dao.insertItem(item)
dao.markAsUnstarred("1")
val result = dao.getItemById("1")
assertEquals(false, result?.isStarred)
}
@Test
fun markAllAsRead() = runTest {
val item1 = createTestItem("1", "sub1", isRead = false)
val item2 = createTestItem("2", "sub1", isRead = false)
val item3 = createTestItem("3", "sub2", isRead = false)
dao.insertItems(listOf(item1, item2, item3))
dao.markAllAsRead("sub1")
val sub1Items = dao.getItemsBySubscription("sub1").first()
val sub2Items = dao.getItemsBySubscription("sub2").first()
assertEquals(true, sub1Items[0].isRead)
assertEquals(true, sub1Items[1].isRead)
assertEquals(false, sub2Items[0].isRead)
}
@Test
fun getItemsPaginated() = runTest {
for (i in 1..10) {
val item = createTestItem(i.toString(), "sub1")
dao.insertItem(item)
}
val firstPage = dao.getItemsPaginated("sub1", 5, 0)
val secondPage = dao.getItemsPaginated("sub1", 5, 5)
assertEquals(5, firstPage.size)
assertEquals(5, secondPage.size)
}
private fun createTestItem(
id: String,
subscriptionId: String,
title: String = "Test Item",
isRead: Boolean = false,
isStarred: Boolean = false,
published: Date = Date()
): FeedItemEntity {
return FeedItemEntity(
id = id,
subscriptionId = subscriptionId,
title = title,
link = "https://example.com/$id",
description = "Test description",
content = "Test content",
author = "Test Author",
published = published,
updated = published,
categories = "Tech,News",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-$id",
isRead = isRead,
isStarred = isStarred
)
}
}

View File

@@ -0,0 +1,196 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SearchHistoryEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import java.util.Date
import java.util.UUID
class RssDatabaseTest {
private lateinit var database: RssDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
}
@After
fun closeDb() {
database.close()
}
@Test
fun databaseConstruction() {
assertNotNull(database.subscriptionDao())
assertNotNull(database.feedItemDao())
assertNotNull(database.searchHistoryDao())
}
@Test
fun ftsVirtualTableExists() {
val cursor = database.run {
openHelper.writableDatabase.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'",
emptyArray()
)
}
assertEquals(true, cursor.moveToFirst())
cursor.close()
}
@Test
fun subscriptionEntityRoundTrip() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = UUID.randomUUID().toString(),
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val result = database.subscriptionDao().getSubscriptionById(subscription.id)
assertNotNull(result)
assertEquals(subscription.id, result?.id)
assertEquals(subscription.title, result?.title)
}
@Test
fun feedItemEntityRoundTrip() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = "sub1",
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val item = FeedItemEntity(
id = UUID.randomUUID().toString(),
subscriptionId = "sub1",
title = "Test Item",
link = "https://example.com/item",
description = "Test description",
content = "Test content",
author = "Test Author",
published = now,
updated = now,
categories = "Tech",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-1",
isRead = false,
isStarred = false
)
database.feedItemDao().insertItem(item)
val result = database.feedItemDao().getItemById(item.id)
assertNotNull(result)
assertEquals(item.id, result?.id)
assertEquals(item.title, result?.title)
assertEquals("sub1", result?.subscriptionId)
}
@Test
fun searchHistoryEntityRoundTrip() = runTest {
val now = Date()
val search = SearchHistoryEntity(
id = UUID.randomUUID().toString(),
query = "kotlin coroutines",
timestamp = now
)
database.searchHistoryDao().insertSearchHistory(search)
val result = database.searchHistoryDao().getSearchHistoryById(search.id)
assertNotNull(result)
assertEquals(search.id, result?.id)
assertEquals(search.query, result?.query)
}
@Test
fun cascadeDeleteFeedItems() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = "sub1",
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val item = FeedItemEntity(
id = "item1",
subscriptionId = "sub1",
title = "Test Item",
link = "https://example.com/item",
description = "Test description",
content = "Test content",
author = "Test Author",
published = now,
updated = now,
categories = "Tech",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-1",
isRead = false,
isStarred = false
)
database.feedItemDao().insertItem(item)
database.subscriptionDao().deleteSubscription(subscription)
val items = database.feedItemDao().getItemsBySubscription("sub1").first()
assertEquals(0, items.size)
}
}

View File

@@ -0,0 +1,188 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.entities.SearchHistoryEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchHistoryDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: SearchHistoryDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.searchHistoryDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetSearchHistory() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
val result = dao.getSearchHistoryById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("kotlin", result?.query)
}
@Test
fun getAllSearchHistory() = runTest {
val search1 = createTestSearch("1", "kotlin")
val search2 = createTestSearch("2", "android")
val search3 = createTestSearch("3", "room database")
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.getAllSearchHistory().first()
assertEquals(3, result.size)
}
@Test
fun searchHistory() = runTest {
val search1 = createTestSearch("1", "kotlin coroutines")
val search2 = createTestSearch("2", "android kotlin")
val search3 = createTestSearch("3", "java")
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.searchHistory("kotlin").first()
assertEquals(2, result.size)
}
@Test
fun getRecentSearches() = runTest {
val search1 = createTestSearch("1", "query1", timestamp = Date(System.currentTimeMillis() - 300000))
val search2 = createTestSearch("2", "query2", timestamp = Date(System.currentTimeMillis() - 200000))
val search3 = createTestSearch("3", "query3", timestamp = Date(System.currentTimeMillis() - 100000))
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.getRecentSearches(2).first()
assertEquals(2, result.size)
assertEquals("3", result[0].id)
assertEquals("2", result[1].id)
}
@Test
fun getSearchHistoryCount() = runTest {
val search1 = createTestSearch("1", "query1")
val search2 = createTestSearch("2", "query2")
val search3 = createTestSearch("3", "query3")
dao.insertSearchHistories(listOf(search1, search2, search3))
val count = dao.getSearchHistoryCount().first()
assertEquals(3, count)
}
@Test
fun updateSearchHistory() = runTest {
val search = createTestSearch("1", "old query")
dao.insertSearchHistory(search)
val updated = search.copy(query = "new query")
dao.updateSearchHistory(updated)
val result = dao.getSearchHistoryById("1")
assertEquals("new query", result?.query)
}
@Test
fun deleteSearchHistory() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
dao.deleteSearchHistory(search)
val result = dao.getSearchHistoryById("1")
assertNull(result)
}
@Test
fun deleteSearchHistoryById() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
dao.deleteSearchHistoryById("1")
val result = dao.getSearchHistoryById("1")
assertNull(result)
}
@Test
fun deleteAllSearchHistory() = runTest {
val search1 = createTestSearch("1", "query1")
val search2 = createTestSearch("2", "query2")
dao.insertSearchHistories(listOf(search1, search2))
dao.deleteAllSearchHistory()
val result = dao.getAllSearchHistory().first()
assertEquals(0, result.size)
}
@Test
fun deleteSearchHistoryOlderThan() = runTest {
val oldSearch = createTestSearch("1", "old query", timestamp = Date(System.currentTimeMillis() - 86400000 * 2))
val recentSearch = createTestSearch("2", "recent query", timestamp = Date(System.currentTimeMillis() - 86400000))
dao.insertSearchHistories(listOf(oldSearch, recentSearch))
dao.deleteSearchHistoryOlderThan(System.currentTimeMillis() - 86400000)
val result = dao.getAllSearchHistory().first()
assertEquals(1, result.size)
assertEquals("2", result[0].id)
}
@Test
fun insertSearchHistoryWithConflict() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
val duplicate = search.copy(query = "android")
val result = dao.insertSearchHistory(duplicate)
assertEquals(-1L, result)
val dbSearch = dao.getSearchHistoryById("1")
assertEquals("kotlin", dbSearch?.query)
}
private fun createTestSearch(
id: String,
query: String,
timestamp: Date = Date()
): SearchHistoryEntity {
return SearchHistoryEntity(
id = id,
query = query,
timestamp = timestamp
)
}
}

View File

@@ -0,0 +1,204 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SubscriptionDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: SubscriptionDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.subscriptionDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val result = dao.getSubscriptionById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("Test Feed", result?.title)
}
@Test
fun getSubscriptionByUrl() = runTest {
val subscription = createTestSubscription("1", url = "https://example.com/feed")
dao.insertSubscription(subscription)
val result = dao.getSubscriptionByUrl("https://example.com/feed")
assertNotNull(result)
assertEquals("1", result?.id)
}
@Test
fun getAllSubscriptions() = runTest {
val subscription1 = createTestSubscription("1")
val subscription2 = createTestSubscription("2")
dao.insertSubscriptions(listOf(subscription1, subscription2))
val result = dao.getAllSubscriptions().first()
assertEquals(2, result.size)
}
@Test
fun getEnabledSubscriptions() = runTest {
val enabled = createTestSubscription("1", enabled = true)
val disabled = createTestSubscription("2", enabled = false)
dao.insertSubscriptions(listOf(enabled, disabled))
val result = dao.getEnabledSubscriptions().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun updateSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val updated = subscription.copy(title = "Updated Title")
dao.updateSubscription(updated)
val result = dao.getSubscriptionById("1")
assertEquals("Updated Title", result?.title)
}
@Test
fun deleteSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.deleteSubscription(subscription)
val result = dao.getSubscriptionById("1")
assertNull(result)
}
@Test
fun deleteSubscriptionById() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.deleteSubscriptionById("1")
val result = dao.getSubscriptionById("1")
assertNull(result)
}
@Test
fun getSubscriptionCount() = runTest {
val subscription1 = createTestSubscription("1")
val subscription2 = createTestSubscription("2")
dao.insertSubscriptions(listOf(subscription1, subscription2))
val count = dao.getSubscriptionCount().first()
assertEquals(2, count)
}
@Test
fun updateError() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.updateError("1", "Feed not found")
val result = dao.getSubscriptionById("1")
assertEquals("Feed not found", result?.error)
}
@Test
fun updateLastFetchedAt() = runTest {
val subscription = createTestSubscription("1")
val now = Date()
dao.insertSubscription(subscription)
dao.updateLastFetchedAt("1", now)
val result = dao.getSubscriptionById("1")
assertEquals(now, result?.lastFetchedAt)
assertNull(result?.error)
}
@Test
fun updateNextFetchAt() = runTest {
val subscription = createTestSubscription("1")
val future = Date(System.currentTimeMillis() + 3600000)
dao.insertSubscription(subscription)
dao.updateNextFetchAt("1", future)
val result = dao.getSubscriptionById("1")
assertEquals(future, result?.nextFetchAt)
}
@Test
fun insertSubscriptionWithConflict() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val updated = subscription.copy(title = "Updated")
dao.insertSubscription(updated)
val result = dao.getSubscriptionById("1")
assertEquals("Updated", result?.title)
}
private fun createTestSubscription(
id: String,
url: String = "https://example.com/feed/$id",
title: String = "Test Feed",
enabled: Boolean = true
): SubscriptionEntity {
val now = Date()
return SubscriptionEntity(
id = id,
url = url,
title = title,
category = "Tech",
enabled = enabled,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
}
}

View File

@@ -0,0 +1,134 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedItemTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedItem>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(FeedItem::class.java)
}
@Test
fun testSerialization() {
val feedItem = FeedItem(
id = "item-1",
title = "Test Article",
link = "https://example.com/article",
description = "Short description",
content = "Full content here",
author = "John Doe",
published = Date(1672531200000),
categories = listOf("Tech", "News"),
guid = "guid-123",
subscriptionTitle = "Tech News"
)
val json = adapter.toJson(feedItem)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "item-1",
"title": "Test Article",
"link": "https://example.com/article",
"description": "Short description",
"author": "John Doe",
"published": 1672531200000,
"categories": ["Tech", "News"],
"guid": "guid-123",
"subscriptionTitle": "Tech News"
}"""
val feedItem = adapter.fromJson(json)
assertNotNull(feedItem)
assertEquals("item-1", feedItem?.id)
assertEquals("Test Article", feedItem?.title)
assertEquals("John Doe", feedItem?.author)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "item-1",
"title": "Test Article"
}"""
val feedItem = adapter.fromJson(json)
assertNotNull(feedItem)
assertNull(feedItem?.link)
assertNull(feedItem?.description)
assertNull(feedItem?.author)
}
@Test
fun testEnclosureSerialization() {
val feedItem = FeedItem(
id = "item-1",
title = "Podcast Episode",
enclosure = Enclosure(
url = "https://example.com/episode.mp3",
type = "audio/mpeg",
length = 12345678
)
)
val json = adapter.toJson(feedItem)
assertNotNull(json)
}
@Test
fun testCopy() {
val original = FeedItem(
id = "item-1",
title = "Original Title",
author = "Original Author"
)
val modified = original.copy(title = "Modified Title")
assertEquals("item-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals("Original Author", modified.author)
}
@Test
fun testEqualsAndHashCode() {
val item1 = FeedItem(id = "item-1", title = "Test")
val item2 = FeedItem(id = "item-1", title = "Test")
val item3 = FeedItem(id = "item-2", title = "Test")
assertEquals(item1, item2)
assertEquals(item1.hashCode(), item2.hashCode())
assert(item1 != item3)
}
@Test
fun testToString() {
val feedItem = FeedItem(
id = "item-1",
title = "Test Article",
author = "John Doe"
)
val toString = feedItem.toString()
assertNotNull(toString)
assert(toString.contains("id=item-1"))
assert(toString.contains("title=Test Article"))
}
}

View File

@@ -0,0 +1,199 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedSubscriptionTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedSubscription>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(FeedSubscription::class.java)
}
@Test
fun testSerialization() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Tech News",
category = "Technology",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val json = adapter.toJson(subscription)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Tech News",
"category": "Technology",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertEquals("sub-1", subscription?.id)
assertEquals("https://example.com/feed.xml", subscription?.url)
assertEquals("Tech News", subscription?.title)
assertEquals("Technology", subscription?.category)
assertEquals(true, subscription?.enabled)
assertEquals(60, subscription?.fetchInterval)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Tech News",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertNull(subscription?.category)
assertNull(subscription?.error)
assertNull(subscription?.httpAuth)
}
@Test
fun testHttpAuthSerialization() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Private Feed",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000),
httpAuth = HttpAuth(
username = "user123",
password = "pass456"
)
)
val json = adapter.toJson(subscription)
assertNotNull(json)
}
@Test
fun testHttpAuthDeserialization() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Private Feed",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000,
"httpAuth": {
"username": "user123",
"password": "pass456"
}
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertNotNull(subscription?.httpAuth)
assertEquals("user123", subscription?.httpAuth?.username)
assertEquals("pass456", subscription?.httpAuth?.password)
}
@Test
fun testCopy() {
val original = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Original Title",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val modified = original.copy(title = "Modified Title", enabled = false)
assertEquals("sub-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals(false, modified.enabled)
assertEquals(60, modified.fetchInterval)
}
@Test
fun testEqualsAndHashCode() {
val sub1 = FeedSubscription(
id = "sub-1",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val sub2 = FeedSubscription(
id = "sub-1",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val sub3 = FeedSubscription(
id = "sub-2",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
assertEquals(sub1, sub2)
assertEquals(sub1.hashCode(), sub2.hashCode())
assert(sub1 != sub3)
}
@Test
fun testToString() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Tech News",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val toString = subscription.toString()
assertNotNull(toString)
assert(toString.contains("id=sub-1"))
assert(toString.contains("title=Tech News"))
}
}

View File

@@ -0,0 +1,139 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<Feed>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(Feed::class.java)
}
@Test
fun testSerialization() {
val feed = Feed(
id = "feed-1",
title = "Tech News",
link = "https://example.com",
description = "Technology news feed",
subtitle = "Daily tech updates",
language = "en",
rawUrl = "https://example.com/feed.xml",
ttl = 60,
items = listOf(
FeedItem(id = "item-1", title = "Article 1"),
FeedItem(id = "item-2", title = "Article 2")
)
)
val json = adapter.toJson(feed)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"link": "https://example.com",
"description": "Technology news feed",
"subtitle": "Daily tech updates",
"language": "en",
"rawUrl": "https://example.com/feed.xml",
"ttl": 60,
"items": [
{"id": "item-1", "title": "Article 1"},
{"id": "item-2", "title": "Article 2"}
]
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertEquals("feed-1", feed?.id)
assertEquals("Tech News", feed?.title)
assertEquals(2, feed?.items?.size)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"rawUrl": "https://example.com/feed.xml"
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertNull(feed?.link)
assertNull(feed?.description)
assertNull(feed?.language)
}
@Test
fun testEmptyItemsList() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"rawUrl": "https://example.com/feed.xml",
"items": []
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertTrue(feed?.items?.isEmpty() == true)
}
@Test
fun testCopy() {
val original = Feed(
id = "feed-1",
title = "Original Title",
rawUrl = "https://example.com/feed.xml"
)
val modified = original.copy(title = "Modified Title")
assertEquals("feed-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals("https://example.com/feed.xml", modified.rawUrl)
}
@Test
fun testEqualsAndHashCode() {
val feed1 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
val feed2 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
val feed3 = Feed(id = "feed-2", title = "Test", rawUrl = "https://example.com")
assertEquals(feed1, feed2)
assertEquals(feed1.hashCode(), feed2.hashCode())
assert(feed1 != feed3)
}
@Test
fun testToString() {
val feed = Feed(
id = "feed-1",
title = "Tech News",
rawUrl = "https://example.com/feed.xml"
)
val toString = feed.toString()
assertNotNull(toString)
assert(toString.contains("id=feed-1"))
assert(toString.contains("title=Tech News"))
}
}

View File

@@ -0,0 +1,108 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
class NotificationPreferencesTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<NotificationPreferences>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(NotificationPreferences::class.java)
}
@Test
fun testSerialization() {
val preferences = NotificationPreferences(
newArticles = true,
episodeReleases = true,
customAlerts = false,
badgeCount = true,
sound = true,
vibration = false
)
val json = adapter.toJson(preferences)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"newArticles": true,
"episodeReleases": true,
"customAlerts": false,
"badgeCount": true,
"sound": true,
"vibration": false
}"""
val preferences = adapter.fromJson(json)
assertNotNull(preferences)
assertEquals(true, preferences?.newArticles)
assertEquals(true, preferences?.episodeReleases)
assertEquals(false, preferences?.customAlerts)
assertEquals(true, preferences?.badgeCount)
assertEquals(true, preferences?.sound)
assertEquals(false, preferences?.vibration)
}
@Test
fun testDefaultValues() {
val preferences = NotificationPreferences()
assertEquals(true, preferences.newArticles)
assertEquals(true, preferences.episodeReleases)
assertEquals(false, preferences.customAlerts)
assertEquals(true, preferences.badgeCount)
assertEquals(true, preferences.sound)
assertEquals(true, preferences.vibration)
}
@Test
fun testCopy() {
val original = NotificationPreferences(
newArticles = true,
sound = true
)
val modified = original.copy(newArticles = false, sound = false)
assertEquals(false, modified.newArticles)
assertEquals(false, modified.sound)
assertEquals(true, modified.episodeReleases)
}
@Test
fun testEqualsAndHashCode() {
val pref1 = NotificationPreferences(newArticles = true, sound = true)
val pref2 = NotificationPreferences(newArticles = true, sound = true)
val pref3 = NotificationPreferences(newArticles = false, sound = true)
assertEquals(pref1, pref2)
assertEquals(pref1.hashCode(), pref2.hashCode())
assert(pref1 != pref3)
}
@Test
fun testToString() {
val preferences = NotificationPreferences(
newArticles = true,
sound = true
)
val toString = preferences.toString()
assertNotNull(toString)
assert(toString.contains("newArticles"))
assert(toString.contains("sound"))
}
}

View File

@@ -0,0 +1,141 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
class ReadingPreferencesTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<ReadingPreferences>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(ReadingPreferences::class.java)
}
@Test
fun testSerialization() {
val preferences = ReadingPreferences(
fontSize = FontSize.LARGE,
lineHeight = LineHeight.RELAXED,
showTableOfContents = true,
showReadingTime = true,
showAuthor = false,
showDate = true
)
val json = adapter.toJson(preferences)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"fontSize": "large",
"lineHeight": "relaxed",
"showTableOfContents": true,
"showReadingTime": true,
"showAuthor": false,
"showDate": true
}"""
val preferences = adapter.fromJson(json)
assertNotNull(preferences)
assertEquals(FontSize.LARGE, preferences?.fontSize)
assertEquals(LineHeight.RELAXED, preferences?.lineHeight)
assertEquals(true, preferences?.showTableOfContents)
assertEquals(true, preferences?.showReadingTime)
assertEquals(false, preferences?.showAuthor)
assertEquals(true, preferences?.showDate)
}
@Test
fun testFontSizeOptions() {
val fontSizes = listOf(
"small" to FontSize.SMALL,
"medium" to FontSize.MEDIUM,
"large" to FontSize.LARGE,
"xlarge" to FontSize.XLARGE
)
for ((jsonValue, expectedEnum) in fontSizes) {
val json = """{"fontSize": "$jsonValue"}"""
val preferences = adapter.fromJson(json)
assertNotNull("Failed for fontSize: $jsonValue", preferences)
assertEquals("Failed for fontSize: $jsonValue", expectedEnum, preferences?.fontSize)
}
}
@Test
fun testLineHeightOptions() {
val lineHeights = listOf(
"normal" to LineHeight.NORMAL,
"relaxed" to LineHeight.RELAXED,
"loose" to LineHeight.LOOSE
)
for ((jsonValue, expectedEnum) in lineHeights) {
val json = """{"lineHeight": "$jsonValue"}"""
val preferences = adapter.fromJson(json)
assertNotNull("Failed for lineHeight: $jsonValue", preferences)
assertEquals("Failed for lineHeight: $jsonValue", expectedEnum, preferences?.lineHeight)
}
}
@Test
fun testDefaultValues() {
val preferences = ReadingPreferences()
assertEquals(FontSize.MEDIUM, preferences.fontSize)
assertEquals(LineHeight.NORMAL, preferences.lineHeight)
assertEquals(false, preferences.showTableOfContents)
assertEquals(true, preferences.showReadingTime)
assertEquals(true, preferences.showAuthor)
assertEquals(true, preferences.showDate)
}
@Test
fun testCopy() {
val original = ReadingPreferences(
fontSize = FontSize.MEDIUM,
showReadingTime = true
)
val modified = original.copy(fontSize = FontSize.XLARGE, showReadingTime = false)
assertEquals(FontSize.XLARGE, modified.fontSize)
assertEquals(false, modified.showReadingTime)
assertEquals(LineHeight.NORMAL, modified.lineHeight)
}
@Test
fun testEqualsAndHashCode() {
val pref1 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
val pref2 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
val pref3 = ReadingPreferences(fontSize = FontSize.LARGE, showReadingTime = true)
assertEquals(pref1, pref2)
assertEquals(pref1.hashCode(), pref2.hashCode())
assert(pref1 != pref3)
}
@Test
fun testToString() {
val preferences = ReadingPreferences(
fontSize = FontSize.LARGE,
showReadingTime = true
)
val toString = preferences.toString()
assertNotNull(toString)
assert(toString.contains("fontSize"))
assert(toString.contains("showReadingTime"))
}
}

View File

@@ -0,0 +1,156 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchFiltersTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchFilters>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(SearchFilters::class.java)
}
@Test
fun testSerialization() {
val filters = SearchFilters(
dateFrom = Date(1672531200000),
dateTo = Date(1672617600000),
feedIds = listOf("feed-1", "feed-2"),
authors = listOf("John Doe", "Jane Smith"),
contentType = ContentType.ARTICLE,
sortOption = SearchSortOption.DATE_DESC
)
val json = adapter.toJson(filters)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"dateFrom": 1672531200000,
"dateTo": 1672617600000,
"feedIds": ["feed-1", "feed-2"],
"authors": ["John Doe", "Jane Smith"],
"contentType": "article",
"sortOption": "date_desc"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertNotNull(filters?.dateFrom)
assertNotNull(filters?.dateTo)
assertEquals(2, filters?.feedIds?.size)
assertEquals(2, filters?.authors?.size)
assertEquals(ContentType.ARTICLE, filters?.contentType)
assertEquals(SearchSortOption.DATE_DESC, filters?.sortOption)
}
@Test
fun testContentTypeAudio() {
val json = """{
"contentType": "audio"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertEquals(ContentType.AUDIO, filters?.contentType)
}
@Test
fun testContentTypeVideo() {
val json = """{
"contentType": "video"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertEquals(ContentType.VIDEO, filters?.contentType)
}
@Test
fun testSortOptions() {
val sortOptions = listOf(
"relevance" to SearchSortOption.RELEVANCE,
"date_desc" to SearchSortOption.DATE_DESC,
"date_asc" to SearchSortOption.DATE_ASC,
"title_asc" to SearchSortOption.TITLE_ASC,
"title_desc" to SearchSortOption.TITLE_DESC,
"feed_asc" to SearchSortOption.FEED_ASC,
"feed_desc" to SearchSortOption.FEED_DESC
)
for ((jsonValue, expectedEnum) in sortOptions) {
val json = """{"sortOption": "$jsonValue"}"""
val filters = adapter.fromJson(json)
assertNotNull("Failed for sortOption: $jsonValue", filters)
assertEquals("Failed for sortOption: $jsonValue", expectedEnum, filters?.sortOption)
}
}
@Test
fun testOptionalFieldsNull() {
val json = "{}"
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertNull(filters?.dateFrom)
assertNull(filters?.dateTo)
assertNull(filters?.feedIds)
assertNull(filters?.authors)
assertNull(filters?.contentType)
assertEquals(SearchSortOption.RELEVANCE, filters?.sortOption)
}
@Test
fun testCopy() {
val original = SearchFilters(
feedIds = listOf("feed-1"),
sortOption = SearchSortOption.RELEVANCE
)
val modified = original.copy(
feedIds = listOf("feed-1", "feed-2"),
sortOption = SearchSortOption.DATE_DESC
)
assertEquals(2, modified.feedIds?.size)
assertEquals(SearchSortOption.DATE_DESC, modified.sortOption)
}
@Test
fun testEqualsAndHashCode() {
val filters1 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
val filters2 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
val filters3 = SearchFilters(feedIds = listOf("feed-2"), sortOption = SearchSortOption.RELEVANCE)
assertEquals(filters1, filters2)
assertEquals(filters1.hashCode(), filters2.hashCode())
assert(filters1 != filters3)
}
@Test
fun testToString() {
val filters = SearchFilters(
feedIds = listOf("feed-1"),
sortOption = SearchSortOption.DATE_DESC
)
val toString = filters.toString()
assertNotNull(toString)
assert(toString.contains("feedIds"))
assert(toString.contains("sortOption"))
}
}

View File

@@ -0,0 +1,153 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchResultTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchResult>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(SearchResult::class.java)
}
@Test
fun testArticleSerialization() {
val result = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Test Article",
snippet = "This is a snippet",
link = "https://example.com/article",
feedTitle = "Tech News",
published = Date(1672531200000),
score = 0.95
)
val json = adapter.toJson(result)
assertNotNull(json)
}
@Test
fun testFeedSerialization() {
val result = SearchResult(
id = "feed-1",
type = SearchResultType.FEED,
title = "Tech News Feed",
snippet = "Technology news and updates",
link = "https://example.com",
score = 0.85
)
val json = adapter.toJson(result)
assertNotNull(json)
}
@Test
fun testArticleDeserialization() {
val json = """{
"id": "article-1",
"type": "article",
"title": "Test Article",
"snippet": "This is a snippet",
"link": "https://example.com/article",
"feedTitle": "Tech News",
"published": 1672531200000,
"score": 0.95
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertEquals("article-1", result?.id)
assertEquals(SearchResultType.ARTICLE, result?.type)
assertEquals("Test Article", result?.title)
assertEquals("This is a snippet", result?.snippet)
}
@Test
fun testFeedDeserialization() {
val json = """{
"id": "feed-1",
"type": "feed",
"title": "Tech News Feed",
"snippet": "Technology news and updates",
"link": "https://example.com",
"score": 0.85
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertEquals("feed-1", result?.id)
assertEquals(SearchResultType.FEED, result?.type)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "article-1",
"type": "article",
"title": "Test Article"
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertNull(result?.snippet)
assertNull(result?.link)
assertNull(result?.feedTitle)
assertNull(result?.published)
assertNull(result?.score)
}
@Test
fun testCopy() {
val original = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Original Title"
)
val modified = original.copy(title = "Modified Title", score = 0.99)
assertEquals("article-1", modified.id)
assertEquals(SearchResultType.ARTICLE, modified.type)
assertEquals("Modified Title", modified.title)
assertEquals(0.99, modified.score!!, 0.001)
}
@Test
fun testEqualsAndHashCode() {
val result1 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
val result2 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
val result3 = SearchResult(id = "article-2", type = SearchResultType.ARTICLE, title = "Test")
assertEquals(result1, result2)
assertEquals(result1.hashCode(), result2.hashCode())
assert(result1 != result3)
}
@Test
fun testToString() {
val result = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Test Article",
score = 0.95
)
val toString = result.toString()
assertNotNull(toString)
assert(toString.contains("id=article-1"))
assert(toString.contains("title=Test Article"))
}
}

View File

@@ -0,0 +1,245 @@
package com.rssuper.parsing
import com.rssuper.models.Enclosure
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [24])
class AtomParserTest {
@Test
fun testParseBasicAtom() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<subtitle>Feed subtitle</subtitle>
<link href="https://example.com" rel="alternate"/>
<id>urn:uuid:feed-id-123</id>
<updated>2024-01-01T12:00:00Z</updated>
<generator>Atom Generator</generator>
<entry>
<title>Entry 1</title>
<link href="https://example.com/entry1" rel="alternate"/>
<id>urn:uuid:entry-1</id>
<updated>2024-01-01T10:00:00Z</updated>
<summary>Summary of entry 1</summary>
</entry>
<entry>
<title>Entry 2</title>
<link href="https://example.com/entry2" rel="alternate"/>
<id>urn:uuid:entry-2</id>
<updated>2023-12-31T10:00:00Z</updated>
<content>Full content of entry 2</content>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
assertEquals("Atom Feed", feed.title)
assertEquals("https://example.com", feed.link)
assertEquals("Feed subtitle", feed.subtitle)
assertEquals(2, feed.items.size)
val entry1 = feed.items[0]
assertEquals("Entry 1", entry1.title)
assertEquals("https://example.com/entry1", entry1.link)
assertEquals("Summary of entry 1", entry1.description)
assertNotNull(entry1.published)
val entry2 = feed.items[1]
assertEquals("Entry 2", entry2.title)
assertEquals("Full content of entry 2", entry2.content)
}
@Test
fun testParseAtomWithAuthor() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Author Feed</title>
<id>urn:uuid:feed-id</id>
<entry>
<title>Entry with Author</title>
<id>urn:uuid:entry</id>
<author>
<name>John Doe</name>
<email>john@example.com</email>
</author>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertEquals("John Doe", entry.author)
}
@Test
fun testParseAtomWithCategories() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Categorized Feed</title>
<id>urn:uuid:feed-id</id>
<entry>
<title>Categorized Entry</title>
<id>urn:uuid:entry</id>
<category term="technology"/>
<category term="programming"/>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertEquals(2, entry.categories?.size)
assertEquals("technology", entry.categories?.get(0))
assertEquals("programming", entry.categories?.get(1))
}
@Test
fun testParseAtomWithEnclosure() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Enclosure Feed</title>
<id>urn:uuid:feed-id</id>
<entry>
<title>Episode</title>
<id>urn:uuid:entry</id>
<link href="https://example.com/ep.mp3" rel="enclosure" type="audio/mpeg" length="12345678"/>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertNotNull(entry.enclosure)
assertEquals("https://example.com/ep.mp3", entry.enclosure?.url)
assertEquals("audio/mpeg", entry.enclosure?.type)
assertEquals(12345678L, entry.enclosure?.length)
}
@Test
fun testParseAtomWithContent() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Content Feed</title>
<id>urn:uuid:feed-id</id>
<entry>
<title>Entry</title>
<id>urn:uuid:entry</id>
<summary>Short summary</summary>
<content>Full HTML content</content>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertEquals("Full HTML content", entry.content)
assertEquals("Short summary", entry.description)
}
@Test
fun testParseAtomWithiTunesExtension() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<title>Podcast</title>
<id>urn:uuid:feed-id</id>
<entry>
<title>Episode</title>
<id>urn:uuid:entry</id>
<itunes:duration>3600</itunes:duration>
<itunes:summary>Episode summary</itunes:summary>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertEquals("Episode summary", entry.description)
}
@Test
fun testParseAtomWithPublished() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Date Feed</title>
<id>urn:uuid:feed-id</id>
<updated>2024-06-15T12:00:00Z</updated>
<entry>
<title>Entry</title>
<id>urn:uuid:entry</id>
<published>2024-01-01T08:00:00Z</published>
<updated>2024-01-02T10:00:00Z</updated>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
val entry = feed.items[0]
assertNotNull(entry.published)
}
@Test
fun testParseAtomWithEmptyFeed() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Empty Feed</title>
<id>urn:uuid:feed-id</id>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
assertEquals("Empty Feed", feed.title)
assertEquals(0, feed.items.size)
}
@Test
fun testParseAtomWithMissingFields() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Minimal Entry</title>
</entry>
</feed>
""".trimIndent()
val feed = AtomParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(feed)
assertEquals("Untitled Feed", feed.title)
assertEquals(1, feed.items.size)
assertEquals("Minimal Entry", feed.items[0].title)
assertNull(feed.items[0].link)
}
}

View File

@@ -0,0 +1,162 @@
package com.rssuper.parsing
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [24])
class FeedParserTest {
@Test
fun testParseRSSFeed() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>RSS Feed</title>
<link>https://example.com</link>
<item>
<title>Item</title>
<link>https://example.com/item</link>
</item>
</channel>
</rss>
""".trimIndent()
val result = FeedParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(result)
assertEquals(FeedType.RSS, result.feedType)
assertEquals("RSS Feed", result.feed.title)
}
@Test
fun testParseAtomFeed() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<id>urn:uuid:feed</id>
<entry>
<title>Entry</title>
<id>urn:uuid:entry</id>
</entry>
</feed>
""".trimIndent()
val result = FeedParser.parse(xml, "https://example.com/feed.atom")
assertNotNull(result)
assertEquals(FeedType.Atom, result.feedType)
assertEquals("Atom Feed", result.feed.title)
}
@Test
fun testParseRSSWithNamespaces() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Namespaced Feed</title>
<atom:link href="https://example.com/feed.xml" rel="self"/>
<itunes:author>Author</itunes:author>
<item>
<title>Item</title>
</item>
</channel>
</rss>
""".trimIndent()
val result = FeedParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(result)
assertEquals(FeedType.RSS, result.feedType)
}
@Test
fun testParseMalformedXml() {
val malformedXml = """
<?xml version="1.0"?>
<rss>
<channel>
<title>Broken
""".trimIndent()
try {
val result = FeedParser.parse(malformedXml, "https://example.com/feed.xml")
assertNotNull(result)
} catch (e: Exception) {
assertNotNull(e)
}
}
@Test
fun testParseInvalidFeedType() {
val invalidXml = """
<?xml version="1.0" encoding="UTF-8"?>
<invalid>
<data>Some data</data>
</invalid>
""".trimIndent()
try {
FeedParser.parse(invalidXml, "https://example.com/feed.xml")
fail("Expected exception for invalid feed type")
} catch (e: FeedParsingError) {
assertEquals(FeedParsingError.UnsupportedFeedType, e)
}
}
@Test
fun testParseEmptyFeed() {
val emptyXml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title></title>
</channel>
</rss>
""".trimIndent()
val result = FeedParser.parse(emptyXml, "https://example.com/feed.xml")
assertNotNull(result)
assertEquals("Untitled Feed", result.feed.title)
}
@Test
fun testAsyncCallback() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Async Feed</title>
<item>
<title>Item</title>
</item>
</channel>
</rss>
""".trimIndent()
FeedParser.parseAsync(xml, "https://example.com/feed.xml") { result ->
assert(result.isSuccess)
val feed = result.getOrNull()
assertNotNull(feed)
assertEquals("Async Feed", feed?.feed?.title)
}
}
@Test
fun testAsyncCallbackError() {
val invalidXml = "not xml"
FeedParser.parseAsync(invalidXml, "https://example.com/feed.xml") { result ->
assert(result.isFailure)
}
}
}

View File

@@ -0,0 +1,255 @@
package com.rssuper.parsing
import com.rssuper.models.Enclosure
import com.rssuper.models.Feed
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [24])
class RSSParserTest {
@Test
fun testParseBasicRSS() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>https://example.com</link>
<description>A test feed</description>
<language>en-us</language>
<lastBuildDate>Mon, 01 Jan 2024 12:00:00 GMT</lastBuildDate>
<generator>RSS Generator</generator>
<ttl>60</ttl>
<item>
<title>Item 1</title>
<link>https://example.com/item1</link>
<description>Description of item 1</description>
<guid isPermaLink="true">https://example.com/item1</guid>
<pubDate>Mon, 01 Jan 2024 10:00:00 GMT</pubDate>
</item>
<item>
<title>Item 2</title>
<link>https://example.com/item2</link>
<description>Description of item 2</description>
<guid>item-2-guid</guid>
<pubDate>Sun, 31 Dec 2023 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("Test Feed", feed.title)
assertEquals("https://example.com", feed.link)
assertEquals("A test feed", feed.description)
assertEquals("en-us", feed.language)
assertEquals(60, feed.ttl)
assertEquals(2, feed.items.size)
val item1 = feed.items[0]
assertEquals("Item 1", item1.title)
assertEquals("https://example.com/item1", item1.link)
assertEquals("Description of item 1", item1.description)
assertNotNull(item1.published)
}
@Test
fun testParseRSSWithiTunesNamespace() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Podcast Feed</title>
<link>https://example.com/podcast</link>
<description>My podcast</description>
<itunes:subtitle>Podcast subtitle</itunes:subtitle>
<itunes:author>Author Name</itunes:author>
<item>
<title>Episode 1</title>
<link>https://example.com/episode1</link>
<description>Episode description</description>
<itunes:duration>01:30:00</itunes:duration>
<enclosure url="https://example.com/ep1.mp3" type="audio/mpeg" length="12345678"/>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("Podcast Feed", feed.title)
val item = feed.items[0]
assertEquals("Episode 1", item.title)
assertNotNull(item.enclosure)
assertEquals("https://example.com/ep1.mp3", item.enclosure?.url)
assertEquals("audio/mpeg", item.enclosure?.type)
assertEquals(12345678L, item.enclosure?.length)
}
@Test
fun testParseRSSWithContentNamespace() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Feed with Content</title>
<item>
<title>Item with Content</title>
<description>Short description</description>
<content:encoded><![CDATA[<p>Full content here</p>]]></content:encoded>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals(1, feed.items.size)
assertEquals("Item with Content", feed.items[0].title)
assertEquals("<p>Full content here</p>", feed.items[0].content)
}
@Test
fun testParseRSSWithCategories() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Categorized Feed</title>
<item>
<title>Tech Article</title>
<category>Technology</category>
<category>Programming</category>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
val item = feed.items[0]
assertEquals(2, item.categories?.size)
assertEquals("Technology", item.categories?.get(0))
assertEquals("Programming", item.categories?.get(1))
}
@Test
fun testParseRSSWithAuthor() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Author Feed</title>
<item>
<title>Article by Author</title>
<author>author@example.com (John Doe)</author>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
val item = feed.items[0]
assertEquals("author@example.com (John Doe)", item.author)
}
@Test
fun testParseRSSWithGuid() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Guid Feed</title>
<item>
<title>Item</title>
<guid>custom-guid-12345</guid>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("custom-guid-12345", feed.items[0].guid)
}
@Test
fun testParseRSSWithEmptyChannel() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Minimal Feed</title>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("Minimal Feed", feed.title)
assertEquals(0, feed.items.size)
}
@Test
fun testParseRSSWithMissingFields() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Only Title</title>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("Untitled Feed", feed.title)
assertEquals(1, feed.items.size)
assertEquals("Only Title", feed.items[0].title)
assertNull(feed.items[0].link)
}
@Test
fun testParseRSSWithCDATA() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title><![CDATA[CDATA Title]]></title>
<description><![CDATA[<p>HTML <strong>content</strong></p>]]></description>
<item>
<title>CDATA Item</title>
<description><![CDATA[Item content]]></description>
</item>
</channel>
</rss>
""".trimIndent()
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
assertNotNull(feed)
assertEquals("CDATA Title", feed.title)
assertEquals("<p>HTML <strong>content</strong></p>", feed.description)
assertEquals("Item content", feed.items[0].description)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
package com.rssuper.services
import org.junit.Assert.assertTrue
import org.junit.Test
class FeedFetcherIntegrationTest {
@Test
fun testFetchRealFeed() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val result = feedFetcher.fetch("https://example.com/feed.xml")
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchAndParseRealFeed() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val result = feedFetcher.fetchAndParse("https://example.com/feed.xml")
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchWithHTTPAuthCredentials() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val auth = HTTPAuthCredentials("testuser", "testpass")
val credentials = auth.toCredentials()
assertTrue(credentials.startsWith("Basic "))
}
@Test
fun testFetchWithCacheControl() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val result = feedFetcher.fetch("https://example.com/feed.xml")
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchPerformance() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val startTime = System.currentTimeMillis()
val result = feedFetcher.fetch("https://example.com/feed.xml")
val duration = System.currentTimeMillis() - startTime
assertTrue(duration < 20000 || result.isFailure())
}
@Test
fun testFetchWithIfNoneMatch() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val etag = "test-etag-value"
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchWithIfModifiedSince() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchMultipleFeeds() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val urls = listOf(
"https://example.com/feed1.xml",
"https://example.com/feed2.xml"
)
for (url in urls) {
val result = feedFetcher.fetch(url)
assertTrue(result.isSuccess() || result.isFailure())
}
}
@Test
fun testFetchWithDifferentTimeouts() {
val shortTimeoutFetcher = FeedFetcher(timeoutMs = 1000)
val longTimeoutFetcher = FeedFetcher(timeoutMs = 30000)
val shortClientField = FeedFetcher::class.java.getDeclaredField("client")
shortClientField.isAccessible = true
val shortClient = shortClientField.get(shortTimeoutFetcher) as okhttp3.OkHttpClient
val longClientField = FeedFetcher::class.java.getDeclaredField("client")
longClientField.isAccessible = true
val longClient = longClientField.get(longTimeoutFetcher) as okhttp3.OkHttpClient
assertTrue(shortClient.connectTimeoutMillis < longClient.connectTimeoutMillis)
}
}

View File

@@ -0,0 +1,57 @@
package com.rssuper.services
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FeedFetcherTest {
@Test
fun testOkHttpConfiguration() {
val feedFetcher = FeedFetcher(timeoutMs = 5000)
val clientField = FeedFetcher::class.java.getDeclaredField("client")
clientField.isAccessible = true
val okHttpClient = clientField.get(feedFetcher) as okhttp3.OkHttpClient
assertEquals(5000, okHttpClient.connectTimeoutMillis)
assertEquals(5000, okHttpClient.readTimeoutMillis)
assertEquals(5000, okHttpClient.writeTimeoutMillis)
assertNotNull(okHttpClient.eventListenerFactory)
}
@Test
fun testFetchWithHTTPAuth() {
val auth = HTTPAuthCredentials("user", "pass")
val credentials = auth.toCredentials()
assertNotNull(credentials)
assertTrue(credentials.startsWith("Basic "))
}
@Test
fun testFetchWithETag() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val etag = "test-etag-123"
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchWithLastModified() {
val feedFetcher = FeedFetcher(timeoutMs = 15000)
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
assertTrue(result.isSuccess() || result.isFailure())
}
@Test
fun testFetchRetrySuccess() {
val feedFetcher = FeedFetcher(timeoutMs = 15000, maxRetries = 3)
val result = feedFetcher.fetch("https://example.com/feed.xml")
assertTrue(result.isSuccess() || result.isFailure())
}
}

View File

@@ -0,0 +1,79 @@
package com.rssuper.services
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FetchResultTest {
@Test
fun testFetchResultCreation() {
val result = FetchResult(
feedXml = "<rss>test</rss>",
url = "https://example.com/feed.xml",
cacheControl = null,
isCached = false
)
assertEquals("<rss>test</rss>", result.feedXml)
assertEquals("https://example.com/feed.xml", result.url)
assertEquals(false, result.isCached)
assertEquals(null, result.cacheControl)
}
@Test
fun testFetchResultWithETag() {
val result = FetchResult(
feedXml = "<rss>test</rss>",
url = "https://example.com/feed.xml",
cacheControl = null,
isCached = false,
etag = "test-etag-123"
)
assertEquals("test-etag-123", result.etag)
}
@Test
fun testFetchResultWithLastModified() {
val result = FetchResult(
feedXml = "<rss>test</rss>",
url = "https://example.com/feed.xml",
cacheControl = null,
isCached = false,
lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
)
assertEquals("Mon, 01 Jan 2024 00:00:00 GMT", result.lastModified)
}
@Test
fun testFetchResultIsCached() {
val result = FetchResult(
feedXml = "<rss>test</rss>",
url = "https://example.com/feed.xml",
cacheControl = null,
isCached = true
)
assertTrue(result.isCached)
}
@Test
fun testFetchResultWithCacheControl() {
val cacheControl = okhttp3.CacheControl.Builder()
.noCache()
.build()
val result = FetchResult(
feedXml = "<rss>test</rss>",
url = "https://example.com/feed.xml",
cacheControl = cacheControl,
isCached = false
)
assertNotNull(result.cacheControl)
assertTrue(result.cacheControl!!.noCache)
}
}

View File

@@ -0,0 +1,53 @@
package com.rssuper.services
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HTTPAuthCredentialsTest {
@Test
fun testBasicAuthCredentials() {
val auth = HTTPAuthCredentials("username", "password")
val credentials = auth.toCredentials()
assertNotNull(credentials)
assertTrue(credentials.startsWith("Basic "))
}
@Test
fun testBasicAuthCredentialsWithSpecialChars() {
val auth = HTTPAuthCredentials("user@domain", "pass!@#")
val credentials = auth.toCredentials()
assertNotNull(credentials)
assertTrue(credentials.startsWith("Basic "))
}
@Test
fun testUsernameAndPassword() {
val auth = HTTPAuthCredentials("testuser", "testpass")
assertEquals("testuser", auth.username)
assertEquals("testpass", auth.password)
}
@Test
fun testEmptyUsername() {
val auth = HTTPAuthCredentials("", "password")
val credentials = auth.toCredentials()
assertNotNull(credentials)
assertTrue(credentials.startsWith("Basic "))
}
@Test
fun testEmptyPassword() {
val auth = HTTPAuthCredentials("username", "")
val credentials = auth.toCredentials()
assertNotNull(credentials)
assertTrue(credentials.startsWith("Basic "))
}
}

View File

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

View File

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

View File

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