diff --git a/.github/workflows/android_prod_release.yml b/.github/workflows/android_prod_release.yml index 3e00fe3ed..6fc276cae 100644 --- a/.github/workflows/android_prod_release.yml +++ b/.github/workflows/android_prod_release.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Configure Tramline id: tramline - uses: tramlinehq/deploy-action@v0.1.6 + uses: tramlinehq/deploy-action@v0.1.7 with: input: ${{ github.event.inputs.tramline-input }} diff --git a/.github/workflows/ios_prod_release.yml b/.github/workflows/ios_prod_release.yml index 527e99b01..b64e760f8 100644 --- a/.github/workflows/ios_prod_release.yml +++ b/.github/workflows/ios_prod_release.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Configure Tramline id: tramline - uses: tramlinehq/deploy-action@v0.1.6 + uses: tramlinehq/deploy-action@v0.1.7 with: input: ${{ github.event.inputs.tramline-input }} diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml index 931b96c3c..16660f1d8 100644 --- a/.idea/runConfigurations.xml +++ b/.idea/runConfigurations.xml @@ -5,8 +5,12 @@ diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml index 9298202cb..539e3b805 100644 --- a/.idea/studiobot.xml +++ b/.idea/studiobot.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index 292753a8f..1b3e23af4 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -22,6 +22,8 @@ android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" android:enableOnBackInvokedCallback="true" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="tiramisu" android:largeHeap="true"> diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt index 239e96325..014efe560 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt @@ -25,16 +25,19 @@ import androidx.work.WorkerParameters import co.touchlab.crashkios.bugsnag.BugsnagKotlin import com.bugsnag.android.Bugsnag import dev.sasikanth.rss.reader.data.repository.RssRepository +import dev.sasikanth.rss.reader.data.repository.SettingsRepository import dev.sasikanth.rss.reader.refresh.LastUpdatedAt import java.lang.Exception import java.time.Duration import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first class FeedsRefreshWorker( context: Context, workerParameters: WorkerParameters, private val rssRepository: RssRepository, - private val lastUpdatedAt: LastUpdatedAt + private val lastUpdatedAt: LastUpdatedAt, + private val settingsRepository: SettingsRepository, ) : CoroutineWorker(context, workerParameters) { companion object { @@ -55,6 +58,8 @@ class FeedsRefreshWorker( } override suspend fun doWork(): Result { + if (settingsRepository.enableAutoSync.first().not()) return Result.failure() + return if (lastUpdatedAt.hasExpired()) { try { rssRepository.updateFeeds() diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt index 719ef8e84..c41b9d6b0 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt @@ -59,7 +59,8 @@ class ReaderApplication : Application(), Configuration.Provider { context = appContext, workerParameters = workerParameters, rssRepository = appComponent.rssRepository, - lastUpdatedAt = appComponent.lastUpdatedAt + lastUpdatedAt = appComponent.lastUpdatedAt, + settingsRepository = appComponent.settingsRepository, ) } PostsCleanUpWorker::class.qualifiedName -> { diff --git a/androidApp/src/androidMain/res/xml/network_security_config.xml b/androidApp/src/androidMain/res/xml/network_security_config.xml new file mode 100644 index 000000000..451657817 --- /dev/null +++ b/androidApp/src/androidMain/res/xml/network_security_config.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt index 65a2d580c..8492e2dfe 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt @@ -74,10 +74,9 @@ class RssRepository( feedLink: String, title: String? = null, feedLastCleanUpAt: Instant? = null, - transformUrl: Boolean = true, ): FeedAddResult { return withContext(ioDispatcher) { - when (val feedFetchResult = feedFetcher.fetch(url = feedLink, transformUrl = transformUrl)) { + when (val feedFetchResult = feedFetcher.fetch(url = feedLink)) { is FeedFetchResult.Success -> { return@withContext try { val feedPayload = feedFetchResult.feedPayload @@ -140,13 +139,7 @@ class RssRepository( val feedsChunk = feedQueries.feeds().executeAsList().chunked(UPDATE_CHUNKS) feedsChunk.map { feeds -> feeds.map { feed -> - launch { - addFeed( - feedLink = feed.link, - transformUrl = false, - feedLastCleanUpAt = feed.lastCleanUpAt - ) - } + launch { addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } } @@ -158,7 +151,7 @@ class RssRepository( withContext(ioDispatcher) { val feed = feedQueries.feed(selectedFeedId).executeAsOneOrNull() if (feed != null) { - addFeed(feedLink = feed.link, transformUrl = false, feedLastCleanUpAt = feed.lastCleanUpAt) + addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } @@ -168,11 +161,7 @@ class RssRepository( feedIds.forEach { feedId -> val feed = feedQueries.feed(feedId).executeAsOneOrNull() if (feed != null) { - addFeed( - feedLink = feed.link, - transformUrl = false, - feedLastCleanUpAt = feed.lastCleanUpAt - ) + addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } @@ -252,7 +241,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - pinnedPosition: Double -> + pinnedPosition: Double, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -264,7 +254,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - pinnedPosition = pinnedPosition + pinnedPosition = pinnedPosition, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -281,6 +272,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIconLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -290,6 +282,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIconLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -345,7 +338,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -357,7 +351,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - numberOfUnreadPosts = numberOfUnreadPosts + numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -383,7 +378,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -395,7 +391,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - numberOfUnreadPosts = numberOfUnreadPosts + numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -627,6 +624,7 @@ class RssRepository( numberOfUnreadPosts: Long, feedIds: List?, feedHomepageLinks: String?, + feedIcons: String?, updatedAt: Instant?, pinnedPosition: Double -> if (type == "group") { @@ -636,6 +634,7 @@ class RssRepository( feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), feedHomepageLinks = feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedIconLinks = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt!!, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -691,6 +690,7 @@ class RssRepository( numberOfUnreadPosts: Long, feedIds: List?, feedHomepageLinks: String?, + feedIcons: String?, updatedAt: Instant? -> if (type == "group") { FeedGroup( @@ -699,6 +699,7 @@ class RssRepository( feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), feedHomepageLinks = feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedIconLinks = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -738,6 +739,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -747,6 +749,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -772,6 +775,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -780,6 +784,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -799,6 +804,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -807,6 +813,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -841,7 +848,8 @@ class RssRepository( createdAt: Instant, pinnedAt: Instant?, lastCleanUpAt: Instant?, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -853,6 +861,7 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt index 5ddb5b3f5..17b2f5b0a 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt @@ -40,6 +40,8 @@ class SettingsRepository(private val dataStore: DataStore) { private val feedsViewModeKey = stringPreferencesKey("pref_feeds_view_mode") private val feedsSortOrderKey = stringPreferencesKey("pref_feeds_sort_order") private val appThemeModeKey = stringPreferencesKey("pref_app_theme_mode_v2") + private val enableAutoSyncKey = booleanPreferencesKey("enable_auto_sync") + private val showFeedFavIconKey = booleanPreferencesKey("show_feed_fav_icon") val browserType: Flow = dataStore.data.map { preferences -> @@ -75,6 +77,16 @@ class SettingsRepository(private val dataStore: DataStore) { mapToAppThemeMode(preferences[appThemeModeKey]) ?: AppThemeMode.Dark } + val enableAutoSync: Flow = + dataStore.data.map { preferences -> preferences[enableAutoSyncKey] ?: true } + + val showFeedFavIcon: Flow = + dataStore.data.map { preferences -> preferences[showFeedFavIconKey] ?: true } + + suspend fun enableAutoSyncImmediate(): Boolean { + return enableAutoSync.first() + } + suspend fun updateFeedsSortOrder(value: FeedsOrderBy) { dataStore.edit { preferences -> preferences[feedsSortOrderKey] = value.name } } @@ -111,6 +123,14 @@ class SettingsRepository(private val dataStore: DataStore) { dataStore.edit { preferences -> preferences[appThemeModeKey] = value.name } } + suspend fun toggleAutoSync(value: Boolean) { + dataStore.edit { preferences -> preferences[enableAutoSyncKey] = value } + } + + suspend fun toggleShowFeedFavIcon(value: Boolean) { + dataStore.edit { preferences -> preferences[showFeedFavIconKey] = value } + } + private fun mapToAppThemeMode(pref: String?): AppThemeMode? { if (pref.isNullOrBlank()) return null return AppThemeMode.valueOf(pref) diff --git a/core/data/src/commonMain/sqldelight/databases/20.db b/core/data/src/commonMain/sqldelight/databases/20.db new file mode 100644 index 000000000..16a9c2340 Binary files /dev/null and b/core/data/src/commonMain/sqldelight/databases/20.db differ diff --git a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Feed.sq b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Feed.sq index 00c3dc176..092b9b93d 100644 --- a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Feed.sq +++ b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Feed.sq @@ -12,7 +12,8 @@ CREATE TABLE feed( pinnedAt INTEGER AS Instant, lastCleanUpAt INTEGER AS Instant, alwaysFetchSourceArticle INTEGER AS Boolean NOT NULL DEFAULT 0, - pinnedPosition REAL NOT NULL DEFAULT 0.0 + pinnedPosition REAL NOT NULL DEFAULT 0.0, + showFeedFavIcon INTEGER AS Boolean NOT NULL DEFAULT 1 ); CREATE INDEX feed_link_index ON feed(link); @@ -51,7 +52,8 @@ SELECT f.pinnedAt, f.lastCleanUpAt, COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, - f.pinnedPosition + f.pinnedPosition, + f.showFeedFavIcon FROM feed f LEFT JOIN post p ON f.id = p.sourceId AND p.date > :postsAfter GROUP BY f.id @@ -80,7 +82,8 @@ SELECT f.pinnedAt, f.lastCleanUpAt, f.alwaysFetchSourceArticle, - COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts + COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, + f.showFeedFavIcon FROM feed f LEFT JOIN post p ON f.id = p.sourceId AND p.date > :postsAfter WHERE f.id = :id @@ -122,7 +125,8 @@ SELECT f.createdAt, f.pinnedAt, f.lastCleanUpAt, - 0 AS numberOfUnreadPosts + 0 AS numberOfUnreadPosts, + f.showFeedFavIcon FROM feed f WHERE f.id IN :feedIds ORDER BY diff --git a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq index 2a4fb2420..fdf6fb7ab 100644 --- a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq +++ b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq @@ -30,6 +30,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt, @@ -46,6 +50,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt, @@ -83,6 +91,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt diff --git a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq index 3b16d5628..f72e74521 100644 --- a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq +++ b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq @@ -22,6 +22,7 @@ SELECT lastCleanUpAt , numberOfUnreadPosts, feedIds, + feedHomepageLinks, feedIcons, updatedAt, pinnedPosition @@ -37,6 +38,7 @@ FROM ( f.lastCleanUpAt, COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, NULL AS feedIds, + NULL AS feedHomepageLinks, NULL AS feedIcons, f.createdAt, f.pinnedAt, @@ -63,6 +65,10 @@ FROM ( FROM feed WHERE INSTR(fg.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(fg.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt, @@ -88,6 +94,7 @@ SELECT lastCleanUpAt , numberOfUnreadPosts, feedIds, + feedHomepageLinks, feedIcons, updatedAt FROM ( @@ -102,6 +109,7 @@ FROM ( f.lastCleanUpAt, COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, NULL AS feedIds, + NULL AS feedHomepageLinks, NULL AS feedIcons, f.createdAt, f.pinnedAt, @@ -127,6 +135,10 @@ FROM ( FROM feed WHERE INSTR(fg.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(fg.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt diff --git a/core/data/src/commonMain/sqldelight/migrations/19.sqm b/core/data/src/commonMain/sqldelight/migrations/19.sqm new file mode 100644 index 000000000..87fd2b965 --- /dev/null +++ b/core/data/src/commonMain/sqldelight/migrations/19.sqm @@ -0,0 +1 @@ +ALTER TABLE feed ADD COLUMN showFeedFavIcon INTEGER NOT NULL DEFAULT 1; diff --git a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt index 6dcd5d487..ba7518edc 100644 --- a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt +++ b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt @@ -33,4 +33,5 @@ data class Feed( val alwaysFetchSourceArticle: Boolean = false, override val sourceType: SourceType = SourceType.Feed, override val pinnedPosition: Double = 0.0, + val showFeedFavIcon: Boolean = true, ) : Source diff --git a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt index 58a8f74bf..2d17d6e0f 100644 --- a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt +++ b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt @@ -23,6 +23,7 @@ data class FeedGroup( val name: String, val feedIds: List, val feedHomepageLinks: List, + val feedIconLinks: List, val numberOfUnreadPosts: Long = 0, val createdAt: Instant, val updatedAt: Instant, diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index f9925b08b..bca199936 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -36,10 +36,12 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.io) implementation(libs.kotlininject.runtime) implementation(libs.ktor.core) implementation(libs.ktor.client.logging) implementation(libs.ksoup) + implementation(libs.ksoup.kotlinx.io) implementation(libs.ktxml) implementation(libs.kermit) implementation(libs.crashkios.bugsnag) diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt b/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt index fd10a7fb2..465375338 100644 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt +++ b/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt @@ -20,12 +20,22 @@ import dev.sasikanth.rss.reader.di.scopes.AppScope import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import me.tatarka.inject.annotations.Provides +import okhttp3.Protocol actual interface NetworkComponent { @Provides @AppScope fun providesHttpClient(): HttpClient { - return httpClient(engine = OkHttp, config = { config { retryOnConnectionFailure(true) } }) + return httpClient( + engine = OkHttp, + config = { + config { + retryOnConnectionFailure(true) + + protocols(listOf(Protocol.HTTP_1_1, Protocol.HTTP_2)) + } + } + ) } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt index 7a3ce5a5f..56ddb4241 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt @@ -17,6 +17,7 @@ package dev.sasikanth.rss.reader.core.network.fetcher import co.touchlab.kermit.Logger import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.parseSource import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.core.model.remote.PostPayload import dev.sasikanth.rss.reader.core.network.parser.FeedParser @@ -25,18 +26,20 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_HR import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_TYPE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.RSS_MEDIA_TYPE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel -import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol import io.ktor.http.Url import io.ktor.http.contentType +import io.ktor.http.isRelativePath +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.asSource import korlibs.io.lang.Charset import korlibs.io.lang.Charsets import kotlinx.datetime.Instant @@ -66,12 +69,11 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe suspend fun fetch(url: String, transformUrl: Boolean = true): FeedFetchResult { return if (url.startsWith("nostr:")) fetchNostrFeed(url) - else fetch(url, transformUrl, redirectCount = 0) + else fetch(url, redirectCount = 0) } private suspend fun fetch( url: String, - transformUrl: Boolean, redirectCount: Int, ): FeedFetchResult { if (redirectCount >= MAX_REDIRECTS_ALLOWED) { @@ -79,9 +81,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } return try { - // We are mainly doing this to avoid creating duplicates while refreshing feeds - // after the app update - val transformedUrl = transformUrl(url, transformUrl) + val transformedUrl = buildFeedUrl(url) val response = httpClient.get(transformedUrl.toString()) when (response.status) { @@ -94,7 +94,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe HttpStatusCode.SeeOther, HttpStatusCode.TemporaryRedirect, HttpStatusCode.PermanentRedirect -> { - handleHttpRedirect(response, transformedUrl.toString(), redirectCount) + handleHttpRedirect(response, transformedUrl, redirectCount) } else -> { FeedFetchResult.HttpStatusError(statusCode = response.status) @@ -105,6 +105,21 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } } + private fun buildFeedUrl(urlString: String): Url { + val url = Url(urlString) + + return if (url.isRelativePath) { + URLBuilder() + .apply { + host = urlString + protocol = url.protocol + } + .build() + } else { + url + } + } + private suspend fun parseContent( response: HttpResponse, url: String, @@ -120,10 +135,10 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe return FeedFetchResult.Success(feedPayload) } - val feedUrl = fetchFeedLinkFromHtmlIfExists(response.bodyAsText(), url) + val feedUrl = fetchFeedLinkFromHtmlIfExists(response.bodyAsChannel(), url) if (feedUrl != url && !feedUrl.isNullOrBlank()) { - return fetch(url = feedUrl, transformUrl = false, redirectCount = redirectCount + 1) + return fetch(url = feedUrl, redirectCount = redirectCount + 1) } throw UnsupportedOperationException() @@ -131,41 +146,26 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe private suspend fun handleHttpRedirect( response: HttpResponse, - url: String, + url: Url, redirectCount: Int ): FeedFetchResult { - val newUrl = response.headers["Location"] - return if (newUrl != url && !newUrl.isNullOrBlank()) { - fetch(url = newUrl, transformUrl = false, redirectCount = redirectCount + 1) - } else { - FeedFetchResult.Error(Exception("Failed to fetch the feed")) - } - } + val headerLocation = response.headers["Location"] + val redirectToUrl = UrlUtils.safeUrl(host = url.host, url = headerLocation) - private fun transformUrl(url: String, transformUrl: Boolean): Url { - return if (transformUrl) { - // Currently Ktor Url parses relative URLs, - // if it fails to properly parse the given URL, it - // default to localhost. - // - // This will cause the network call to fail, - // so we are setting the host manually - // https://youtrack.jetbrains.com/issue/KTOR-360 - URLBuilder() - .apply { - protocol = URLProtocol.HTTPS - host = url.replace(Regex("^https?://"), "") - } - .build() - } else { - URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.build() + if (redirectToUrl == url.toString() || redirectToUrl.isNullOrBlank()) { + return FeedFetchResult.Error(Exception("Failed to fetch the feed")) } + + return fetch(url = redirectToUrl, redirectCount = redirectCount + 1) } - private fun fetchFeedLinkFromHtmlIfExists(htmlContent: String, originalUrl: String): String? { + private fun fetchFeedLinkFromHtmlIfExists( + htmlContent: ByteReadChannel, + originalUrl: String + ): String? { val document = try { - Ksoup.parse(htmlContent) + Ksoup.parseSource(htmlContent.asSource()) } catch (t: Throwable) { return null } @@ -177,10 +177,9 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } ?: return null val link = linkElement.attr(ATTR_HREF) - val host = URLBuilder(originalUrl).build().host - val rootUrl = "https://$host" + val host = UrlUtils.extractHost(originalUrl) - return FeedParser.safeUrl(rootUrl, link) + return UrlUtils.safeUrl(host, link) } private suspend fun fetchNostrFeed(nostrUri: String): FeedFetchResult { diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt index 5aa35f295..e9ed092a1 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt @@ -24,15 +24,16 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_VA import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ATOM_ENTRY import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ATOM_FEED import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CONTENT +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ICON import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUBLISHED import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUBTITLE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUMMARY import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_UPDATED +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -47,6 +48,7 @@ internal object AtomContentParser : ContentParser() { var title: String? = null var description: String? = null var link: String? = null + var iconUrl: String? = null while (parser.next() != EventType.END_TAG) { if (parser.eventType != EventType.START_TAG) continue @@ -65,30 +67,26 @@ internal object AtomContentParser : ContentParser() { description = parser.nextText() } TAG_ATOM_ENTRY -> { - posts.add(readAtomEntry(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readAtomEntry(parser, host)) + } + TAG_ICON -> { + iconUrl = parser.nextText() } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl + val host = UrlUtils.extractHost(link ?: feedUrl) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) } - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } - val iconUrl = FeedParser.feedIcon(host) - return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) @@ -138,9 +136,9 @@ internal object AtomContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && content.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && content.isNullOrBlank())) { return null } @@ -149,7 +147,7 @@ internal object AtomContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = content.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = null ) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt index 512a6d3bc..8b4f2c61e 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt @@ -19,18 +19,15 @@ import co.touchlab.kermit.Logger import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.exceptions.XmlParsingError import dev.sasikanth.rss.reader.util.DispatchersProvider -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol -import io.ktor.http.set import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.core.readBytes -import io.ktor.utils.io.core.release import io.ktor.utils.io.readRemaining import korlibs.io.lang.Charset +import korlibs.io.lang.Charsets import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.io.readByteArray import me.tatarka.inject.annotations.Inject import org.kobjects.ktxml.api.XmlPullParserException import org.kobjects.ktxml.mini.MiniXmlPullParser @@ -45,7 +42,11 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { ): FeedPayload { return try { withContext(dispatchersProvider.io) { - val parser = MiniXmlPullParser(source = content.toCharIterator(charset)) + val parser = + MiniXmlPullParser( + source = content.toCharIterator(charset), + relaxed = true, + ) parser.nextTag() @@ -97,46 +98,23 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { internal const val TAG_UPDATED = "updated" internal const val TAG_FEATURED_IMAGE = "featuredImage" internal const val TAG_COMMENTS = "comments" - internal const val TAG_IMAGE_URL = "imageUrl" internal const val TAG_FEED_IMAGE = "image" + internal const val TAG_ICON = "icon" internal const val ATTR_URL = "url" internal const val ATTR_TYPE = "type" internal const val ATTR_REL = "rel" internal const val ATTR_HREF = "href" + internal const val ATTR_RDF_RESOURCE = "rdf:resource" internal const val ATTR_VALUE_ALTERNATE = "alternate" internal const val ATTR_VALUE_IMAGE = "image/jpeg" fun cleanText(text: String?) = text?.replace(htmlTag, "")?.replace(blankLine, "")?.trim() - fun feedIcon(host: String): String { + fun fallbackFeedIcon(host: String): String { return "https://icon.horse/icon/$host" } - - fun safeUrl(host: String?, url: String?): String? { - if (host.isNullOrBlank()) return null - - return if (!url.isNullOrBlank()) { - if (isAbsoluteUrl(url)) { - URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.buildString() - } else { - URLBuilder(host) - .apply { - set(path = url) - protocol = URLProtocol.HTTPS - } - .buildString() - } - } else { - null - } - } - - private fun isAbsoluteUrl(url: String): Boolean { - val pattern = """^[a-zA-Z][a-zA-Z0-9\+\-\.]*:""".toRegex() - return pattern.containsMatchIn(url) - } } } @@ -148,6 +126,7 @@ private fun ByteReadChannel.toCharIterator( private val DEFAULT_BUFFER_SIZE = 1024L + private var encodingCharset: Charset? = null private var currentIndex = 0 private var currentBuffer = String() @@ -156,13 +135,34 @@ private fun ByteReadChannel.toCharIterator( if (this@toCharIterator.isClosedForRead) return false val packet = runBlocking(context) { this@toCharIterator.readRemaining(DEFAULT_BUFFER_SIZE) } - currentBuffer = buildString { charset.decode(this, packet.readBytes()) } + val bytes = packet.readByteArray() + val encodingRegex = """""".toRegex() + if (encodingCharset == null) { + val encodingContent = buildString { Charsets.UTF8.decode(this, bytes) } + encodingCharset = findEncodingCharset(encodingRegex, encodingContent, charset) + } + + currentBuffer = buildString { (encodingCharset ?: charset).decode(this, bytes) } - packet.release() + packet.close() currentIndex = 0 return currentBuffer.isNotEmpty() } + private fun findEncodingCharset( + encodingRegex: Regex, + encodingContent: String, + fallbackCharset: Charset, + ) = + (encodingRegex.find(encodingContent)?.groupValues?.get(1)?.let { encoding -> + try { + Charset.forName(encoding) + } catch (e: Exception) { + null + } + } + ?: fallbackCharset) + override fun nextChar(): Char { if (!hasNext()) throw NoSuchElementException() return currentBuffer[currentIndex++] diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt index cf736ea47..de8dc8862 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt @@ -18,17 +18,19 @@ package dev.sasikanth.rss.reader.core.network.parser import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.core.model.remote.PostPayload +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_RDF_RESOURCE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CONTENT_ENCODED import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DC_DATE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DESCRIPTION +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEED_IMAGE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB_DATE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_CHANNEL import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_ITEM import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -44,6 +46,7 @@ internal object RDFContentParser : ContentParser() { var title: String? = null var link: String? = null var description: String? = null + var iconUrl: String? = null // Parse channel while (parser.next() != EventType.END_TAG) { @@ -63,6 +66,9 @@ internal object RDFContentParser : ContentParser() { TAG_DESCRIPTION -> { description = parser.nextText() } + TAG_FEED_IMAGE -> { + iconUrl = readFeedIcon(parser) + } else -> parser.skip() } } @@ -72,35 +78,36 @@ internal object RDFContentParser : ContentParser() { when (parser.name) { TAG_RSS_ITEM -> { - posts.add(readRssItem(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readRssItem(parser, host)) } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl + val host = UrlUtils.extractHost(link ?: feedUrl) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) } - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } - val iconUrl = FeedParser.feedIcon(host) - return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) } + private fun readFeedIcon(parser: XmlPullParser): String? { + parser.require(EventType.START_TAG, parser.namespace, TAG_FEED_IMAGE) + val link = parser.getAttributeValue(parser.namespace, ATTR_RDF_RESOURCE) + parser.nextTag() + parser.require(EventType.END_TAG, parser.namespace, TAG_FEED_IMAGE) + return link + } + private fun readRssItem(parser: XmlPullParser, hostLink: String?): PostPayload? { parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_ITEM) @@ -137,9 +144,9 @@ internal object RDFContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && description.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && description.isNullOrBlank())) { return null } @@ -148,7 +155,7 @@ internal object RDFContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = description.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = commentsLink?.trim() ) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt index fcff82ee1..ea3f32bbb 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt @@ -26,15 +26,16 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CON import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DESCRIPTION import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ENCLOSURE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEATURED_IMAGE +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEED_IMAGE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB_DATE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_CHANNEL import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_ITEM import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_URL +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -50,11 +51,12 @@ internal object RSSContentParser : ContentParser() { var title: String? = null var link: String? = null var description: String? = null + var iconUrl: String? = null while (parser.next() != EventType.END_TAG) { if (parser.eventType != EventType.START_TAG) continue - when (val name = parser.name) { + when (parser.name) { TAG_TITLE -> { title = parser.nextText() } @@ -69,35 +71,48 @@ internal object RSSContentParser : ContentParser() { description = parser.nextText() } TAG_RSS_ITEM -> { - posts.add(readRssItem(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readRssItem(parser, host)) + } + TAG_FEED_IMAGE -> { + iconUrl = readFeedIcon(parser) } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl + val host = UrlUtils.extractHost(link ?: feedUrl) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) } - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } - val iconUrl = FeedParser.feedIcon(host) - return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) } + private fun readFeedIcon(parser: XmlPullParser): String? { + parser.require(EventType.START_TAG, parser.namespace, TAG_FEED_IMAGE) + + var imageUrl: String? = null + + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue + if (parser.name == TAG_URL) { + imageUrl = parser.nextText() + } else { + parser.skip() + } + } + + return imageUrl + } + private fun readRssItem(parser: XmlPullParser, hostLink: String?): PostPayload? { parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_ITEM) @@ -146,9 +161,9 @@ internal object RSSContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && description.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && description.isNullOrBlank())) { return null } @@ -157,7 +172,7 @@ internal object RSSContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = description.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = commentsLink?.trim() ) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt index 0b825213c..2b659fe5b 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt @@ -23,9 +23,6 @@ import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol -import io.ktor.http.Url import io.ktor.http.contentType import kotlinx.coroutines.withContext import me.tatarka.inject.annotations.Inject @@ -40,7 +37,8 @@ class FullArticleFetcher( suspend fun fetch(link: String): Result { return withContext(dispatchersProvider.io) { try { - val response = httpClient.get(transformUrlToHttps(link)) + val response = httpClient.config { followRedirects = true }.get(link) + if ( response.status == HttpStatusCode.OK && response.contentType()?.withoutParameters() == ContentType.Text.Html @@ -55,8 +53,4 @@ class FullArticleFetcher( return@withContext Result.failure(IllegalArgumentException("Failed to fetch the post")) } } - - private fun transformUrlToHttps(url: String): Url { - return URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.build() - } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt new file mode 100644 index 000000000..b8a32ecc1 --- /dev/null +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Sasikanth Miriyampalli + * + * 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 + * + * http://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. + */ + +package dev.sasikanth.rss.reader.core.network.utils + +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.set + +object UrlUtils { + + fun extractHost(urlString: String): String { + val host = + if (urlString.startsWith("http://") || urlString.startsWith("https://")) { + Url(urlString).host + } else { + urlString + } + + return if (host == "localhost") { + urlString + } else { + host + } + } + + fun safeUrl(host: String?, url: String?): String? { + if (host.isNullOrBlank()) return null + + return if (!url.isNullOrBlank()) { + if (isAbsoluteUrl(url)) { + URLBuilder(url).buildString() + } else { + URLBuilder().apply { set(host = host, path = url) }.buildString() + } + } else { + null + } + } + + private fun isAbsoluteUrl(url: String): Boolean { + val pattern = """^[a-zA-Z][a-zA-Z0-9\+\-\.]*:""".toRegex() + return pattern.containsMatchIn(url) + } +} diff --git a/gradle.properties b/gradle.properties index 07ec5eeb1..898b325fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,7 @@ org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" #Kotlin kotlin.code.style=official +kotlin.native.cacheKind=none #MPP kotlin.mpp.stability.nowarn=true kotlin.mpp.enableCInteropCommonization=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c06e1cd3..fc10ab40a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,57 +1,57 @@ [versions] -kotlin = "2.0.21" -android_gradle_plugin = "8.7.0" -compose = "1.7.0" +kotlin = "2.1.10" +android_gradle_plugin = "8.8.1" +compose = "1.8.0-alpha03" +compose_material_icons_extended = "1.7.3" -android_sdk_compile = "34" -android_sdk_target = "34" +android_sdk_compile = "35" +android_sdk_target = "35" android_sdk_min = "26" sqldelight = "2.0.2" -ktor = "2.3.12" -kotlinx_coroutines = "1.9.0" -kotlinx_date_time = "0.6.1" +ktor = "3.1.0" +kotlinx_coroutines = "1.10.1" +kotlinx_date_time = "0.6.2" kotlinx_immutable_collections = "0.3.8" -kotlinx_serialization_json = "1.7.3" +kotlinx_serialization_json = "1.8.0" +kotlinx_io = "0.6.0" decompose = "3.0.0" -essenty = "2.1.0" -androidx_activity = "1.9.2" +essenty = "2.4.0" +androidx_activity = "1.10.0" androidx_appcompat = "1.7.0" -androidx_core = "1.13.1" -androidx_collection = "1.4.4" +androidx_core = "1.15.0" +androidx_collection = "1.4.5" androidx_test_runner = "1.6.2" androidx_test_rules = "1.6.1" -androidx_work = "2.9.1" -androidx_datastore = "1.1.1" +androidx_work = "2.10.0" +androidx_datastore = "1.1.2" androidx_browser = "1.8.0" -androidx_annotation = "1.8.2" -coil = "3.0.0-alpha09" -spotless = "6.25.0" +androidx_annotation = "1.9.1" +coil = "3.1.0" +spotless = "7.0.2" ktfmt = "0.44" kotlininject = "0.7.2" -ksp = "2.0.21-1.0.25" +ksp = "2.1.10-1.0.30" material_color_utilities = "1.0.0-alpha01" -ksoup = "0.1.2" +ksoup = "0.2.2" sqliteAndroid = "3.45.0" windowSizeClass = "0.5.0" -desugarJdk = "2.1.2" +desugarJdk = "2.1.4" lyricist = "1.7.0" -atomicfu = "0.25.0" -okio = "3.9.1" +atomicfu = "0.27.0" paging = "3.3.0-alpha02-0.5.1" stately = "2.1.0" -xmlutil = "0.90.1" +xmlutil = "0.90.3" ktxml = "0.3.2" -uri = "0.0.18" -webview = "1.9.40-alpha03" +webview = "1.9.40" uuid = "0.8.4" -bugsnag-plugin = "8.1.0" -bugsnag = "6.8.0" +bugsnag-plugin = "8.2.0" +bugsnag = "6.12.0" crashkios-bugsnag = "0.9.0" -kermit = "2.0.4" -reorderable = "2.3.3" +kermit = "2.0.5" +reorderable = "2.4.3" korlibs = "6.0.0" -filekit = "0.8.3" +filekit = "0.8.8" [libraries] compose_runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -60,7 +60,7 @@ compose_ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } compose_ui_util = { module = "org.jetbrains.compose.ui:ui-util", version.ref = "compose" } compose_material = { module = "org.jetbrains.compose.material:material", version.ref = "compose" } compose_material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose" } -compose_material_icons_extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose" } +compose_material_icons_extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose_material_icons_extended" } compose_resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" } ktor_core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor_client_okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } @@ -73,6 +73,7 @@ kotlinx_datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotlinx_immutable_collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable_collections" } kotlinx_atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } kotlinx_serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serialization_json" } +kotlinx_io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx_io" } sqldelight_driver_android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight_driver_native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } sqldelight_extensions_coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } @@ -95,18 +96,18 @@ androidx_datastore_preferences = { module = "androidx.datastore:datastore-prefer androidx_browser = { module = "androidx.browser:browser", version.ref = "androidx_browser" } androidx_annotation= { module = "androidx.annotation:annotation", version.ref = "androidx_annotation" } coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } -coil_network = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil" } +coil_network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlininject-compiler = { module = 'me.tatarka.inject:kotlin-inject-compiler-ksp', version.ref = 'kotlininject' } kotlininject-runtime = { module = 'me.tatarka.inject:kotlin-inject-runtime', version.ref = 'kotlininject' } material_color_utilities = { module = "dev.sasikanth:material-color-utilities", version.ref = "material_color_utilities" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +ksoup-kotlinx-io = { module = "com.fleeksoft.ksoup:ksoup-kotlinx", version.ref = "ksoup" } sqliteAndroid = { module = "com.github.requery:sqlite-android", version.ref = "sqliteAndroid" } windowSizeClass = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "windowSizeClass" } desugarJdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdk" } lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } -okio = { module = "com.squareup.okio:okio", version.ref = "okio" } paging-common = { module = "app.cash.paging:paging-common", version.ref = "paging" } paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "paging" } stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "stately" } @@ -114,7 +115,6 @@ stately-iso-collections = { module = "co.touchlab:stately-iso-collections", vers xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } ktxml = { module = "org.kobjects.ktxml:core", version.ref = "ktxml" } -uri = { module = "com.eygraber:uri-kmp", version.ref = "uri" } webview = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "webview" } uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } bugsnag = { module = "com.bugsnag:bugsnag-android", version.ref = "bugsnag" } @@ -141,6 +141,6 @@ bugsnag = { id = "com.bugsnag.android.gradle", version.ref = "bugsnag-plugin" } [bundles] compose = [ "compose_runtime", "compose_foundation", "compose_material", "compose_material3", "compose_resources", "compose_ui", "compose_ui_util", "compose_material_icons_extended" ] -kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections", "kotlinx_serialization_json" ] +kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections", "kotlinx_serialization_json", "kotlinx_io" ] androidx_test = [ "androidx_test_runner", "androidx_test_rules" ] xmlutil = [ "xmlutil-core", "xmlutil-serialization" ] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72b8..e18bc253b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index efafb96d4..34ae11985 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -375,6 +375,7 @@ "-framework", "\"shared\"", "-lsqlite3", + "-ld_classic", ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}"; PRODUCT_NAME = "${APP_NAME}"; @@ -392,7 +393,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "V9V8885383"; + DEVELOPMENT_TEAM = V9V8885383; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = V9V8885383; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -412,6 +413,7 @@ "-framework", "\"shared\"", "-lsqlite3", + "-ld_classic", ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}"; PRODUCT_NAME = "${APP_NAME}"; @@ -451,7 +453,7 @@ repositoryURL = "https://github.com/bugsnag/bugsnag-cocoa"; requirement = { kind = exactVersion; - version = 6.30.0; + version = 6.32.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index abc13b3df..7a6ac5781 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -94,6 +94,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { scheduledRefreshFeeds(earliest: Date(timeIntervalSinceNow: 60 * 60)) // 1 hour Task(priority: .background) { do { + let isAutoSyncEnabled = try await applicationComponent.settingsRepository.enableAutoSyncImmediate().boolValue + + if !isAutoSyncEnabled { + task.setTaskCompleted(success: false) + return + } + let hasLastUpdatedAtExpired = try await applicationComponent.lastUpdatedAt.hasExpired().boolValue if hasLastUpdatedAtExpired { try await applicationComponent.rssRepository.updateFeeds() diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index aacb7e3db..87f95e964 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -69,5 +69,10 @@ UIUserInterfaceStyle Automatic + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt index 1696105d1..627ed1293 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt @@ -53,8 +53,8 @@ val DeTwineStrings = settings = "Einstellungen", moreMenuOptions = "Weitere Menüoptionen", settingsHeaderBehaviour = "Verhalten", - settingsHeaderFeedback = "Feedback und Fehlerberichte", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Feedback und Fehlerberichte", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Verwende den In-App-Browser", settingsBrowserTypeSubtitle = @@ -99,8 +99,8 @@ val DeTwineStrings = feedsSearchHint = "Filter", allFeeds = "Feeds", pinnedFeeds = "Angepinnt", - openWebsite = "Website öffnen", markAllAsRead = "Alle als gelesen markieren", + openWebsite = "Website öffnen", noNewPosts = "Keine neuen Beiträge", noNewPostsSubtitle = "Schauen Sie später noch einmal vorbei oder ziehen Sie nach unten, um nach neuen Inhalten zu suchen", @@ -169,4 +169,9 @@ val DeTwineStrings = databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 97abd75bf..da115dcc9 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -64,8 +64,8 @@ val EnTwineStrings = settings = "Settings", moreMenuOptions = "More menu options", settingsHeaderBehaviour = "Behavior", - settingsHeaderFeedback = "Feedback & bug reports", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Feedback & bug reports", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Use in-app browser", settingsBrowserTypeSubtitle = "When turned off, links will open in your default browser.", @@ -107,8 +107,8 @@ val EnTwineStrings = feedsSearchHint = "Filter", allFeeds = "Feeds", pinnedFeeds = "Pinned", - openWebsite = "Open Website", markAllAsRead = "Mark All as Read", + openWebsite = "Open Website", noNewPosts = "No new content", noNewPostsSubtitle = "Check back later or pull down to check for new content now", postsAll = "All articles", @@ -176,4 +176,9 @@ val EnTwineStrings = databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt index 6b78194c3..be379fcc9 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt @@ -50,8 +50,8 @@ val TrTwineStrings = settings = "Ayarlar", moreMenuOptions = "Daha fazla menü seçeneği", settingsHeaderBehaviour = "Davranış", - settingsHeaderFeedback = "Geri bildirim & Hata raporları", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Geri bildirim & Hata raporları", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Uygulama içi tarayıcıyı kullan", settingsBrowserTypeSubtitle = @@ -95,8 +95,8 @@ val TrTwineStrings = feedsSearchHint = "Filtre", allFeeds = "Yayınlar", pinnedFeeds = "Sabitlenmiş", - openWebsite = "Web sitesini aç", markAllAsRead = "Tümünü okundu olarak işaretle", + openWebsite = "Web sitesini aç", noNewPosts = "Yeni gönderi yok", noNewPostsSubtitle = "Daha sonra tekrar kontrol edin veya yeni içeriği şimdi kontrol etmek için aşağı çekin", @@ -140,9 +140,9 @@ val TrTwineStrings = feedsBottomBarNewFeed = "Yeni Yayın", actionPin = "Sabitle", actionUnpin = "Sabitlemeyi Kaldır", + actionDelete = "Sil", actionAddTo = "Şuraya ekle", actionMoveTo = "Şuraya taşı", - actionDelete = "Sil", actionUngroup = "Gruplandırılmamış", createGroup = "Grup oluştur", createFeed = "İçerik ekle", @@ -165,4 +165,9 @@ val TrTwineStrings = databaseMaintainenceTitle = "Lütfen bekleyin...", databaseMaintainenceSubtitle = "Veritabanı bakımı gerçekleştiriliyor, uygulamayı kapatmayın", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 5bd85a663..449befe5b 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -156,6 +156,10 @@ data class TwineStrings( val databaseMaintainenceTitle: String, val databaseMaintainenceSubtitle: String, val cdLoadFullArticle: String, + val enableAutoSyncTitle: String, + val enableAutoSyncDesc: String, + val showFeedFavIconTitle: String, + val showFeedFavIconDesc: String, ) object Locales { diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt index 6121863ea..41180d665 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt @@ -47,8 +47,8 @@ val ZhTwineStrings = settings = "设置", moreMenuOptions = "更多选项", settingsHeaderBehaviour = "行为", - settingsHeaderFeedback = "反馈与错误报告", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "反馈与错误报告", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "使用内置浏览器", settingsBrowserTypeSubtitle = "如果禁用,链接将在默认浏览器中打开。", @@ -89,8 +89,8 @@ val ZhTwineStrings = feedsSearchHint = "筛选", allFeeds = "所有订阅", pinnedFeeds = "已置顶的订阅", - openWebsite = "打开网站", markAllAsRead = "全部标记为已读", + openWebsite = "打开网站", noNewPosts = "暂无新内容", noNewPostsSubtitle = "请稍后检查,或下拉以检查是否有新的内容。", postsAll = "所有文章", @@ -156,4 +156,9 @@ val ZhTwineStrings = databaseMaintainenceTitle = "请稍候...", databaseMaintainenceSubtitle = "正在进行数据库维护,请勿关闭应用", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b1f9eebca..be8b2d3b1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -14,6 +14,7 @@ * limitations under the License. */ import com.android.build.api.dsl.ManagedVirtualDevice +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree @@ -28,7 +29,7 @@ plugins { alias(libs.plugins.kotlin.compose) } -composeCompiler { enableStrongSkippingMode = true } +composeCompiler { featureFlags = setOf(ComposeFeatureFlag.StrongSkipping) } @OptIn(ExperimentalKotlinGradlePluginApi::class) kotlin { @@ -93,10 +94,10 @@ kotlin { implementation(libs.androidx.collection) implementation(libs.material.color.utilities) implementation(libs.ksoup) + implementation(libs.ksoup.kotlinx.io) implementation(libs.windowSizeClass) api(libs.androidx.datastore.okio) api(libs.androidx.datastore.preferences) - api(libs.okio) implementation(libs.paging.common) implementation(libs.paging.compose) implementation(libs.stately.isolate) diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt index 28a665ac6..45537487c 100644 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ApplicationComponent.kt @@ -52,7 +52,7 @@ abstract class ApplicationComponent(@get:Provides val context: Context) : } return AppInfo( - versionName = packageInfo.versionName, + versionName = packageInfo.versionName ?: "0.0.1", versionCode = versionCode, isDebugBuild = (applicationInfo.flags and FLAG_DEBUGGABLE) != 0, cachePath = { context.cacheDir.absolutePath } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt index e80776cd2..4c9a84655 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt @@ -58,6 +58,7 @@ import dev.sasikanth.rss.reader.ui.darkAppColorScheme import dev.sasikanth.rss.reader.ui.lightAppColorScheme import dev.sasikanth.rss.reader.ui.rememberDynamicColorState import dev.sasikanth.rss.reader.util.DispatchersProvider +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting import dev.sasikanth.rss.reader.utils.LocalWindowSizeClass import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject @@ -89,6 +90,7 @@ fun App( LocalShareHandler provides shareHandler, LocalLinkHandler provides linkHandler, LocalDynamicColorState provides dynamicColorState, + LocalShowFeedFavIconSetting provides appState.showFeedFavIcon ) { val isSystemInDarkTheme = isSystemInDarkTheme() val useDarkTheme = diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index ec0357e30..3c8c6078d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn @@ -132,7 +133,9 @@ class AppPresenter( // are finished and we can navigate to next screen scope.launch { withContext(dispatchersProvider.io) { rssRepository.numberOfFeeds().firstOrNull() } - navigation.replaceAll(Config.Home) + if (screenStack.active.instance is Screen.Placeholder) { + navigation.replaceAll(Config.Home) + } } } @@ -286,8 +289,15 @@ class AppPresenter( ) init { - settingsRepository.appThemeMode - .onEach { appThemeMode -> _state.update { it.copy(appThemeMode = appThemeMode) } } + combine( + settingsRepository.appThemeMode, + settingsRepository.showFeedFavIcon, + ) { appThemeMode, showFeedFavIcon -> + Pair(appThemeMode, showFeedFavIcon) + } + .onEach { (appThemeMode, showFeedFavIcon) -> + _state.update { it.copy(appThemeMode = appThemeMode, showFeedFavIcon = showFeedFavIcon) } + } .launchIn(coroutineScope) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt index 83795635b..5b1d6e66f 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt @@ -18,9 +18,16 @@ package dev.sasikanth.rss.reader.app import dev.sasikanth.rss.reader.data.repository.AppThemeMode -data class AppState(val appThemeMode: AppThemeMode) { +data class AppState( + val appThemeMode: AppThemeMode, + val showFeedFavIcon: Boolean, +) { companion object { - val DEFAULT = AppState(appThemeMode = AppThemeMode.Auto) + val DEFAULT = + AppState( + appThemeMode = AppThemeMode.Auto, + showFeedFavIcon = true, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt index 2045da673..003b76b7d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt @@ -116,7 +116,12 @@ internal fun ContextActionItem( Spacer(Modifier.requiredHeight(4.dp)) - Text(text = label, style = MaterialTheme.typography.labelLarge, color = color) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = color, + maxLines = 1, + ) } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt similarity index 66% rename from shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt rename to shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt index 6d172a0f8..54fe3525b 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt @@ -18,7 +18,6 @@ package dev.sasikanth.rss.reader.components.image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RssFeed import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -32,29 +31,41 @@ import coil3.size.Dimension import coil3.size.Size import dev.sasikanth.rss.reader.favicons.FavIconImageLoader import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable -internal fun FeedFavIcon( +internal fun FeedIcon( url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, size: Size = Size(Dimension.Undefined, 500) ) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current Box(modifier.background(Color.White)) { - val context = LocalPlatformContext.current - val imageRequest = ImageRequest.Builder(context).data(url).diskCacheKey(url).size(size).build() - val imageLoader = FavIconImageLoader.get(context) + if (showFeedFavIcon) { + val context = LocalPlatformContext.current + val imageRequest = + ImageRequest.Builder(context).data(url).diskCacheKey(url).size(size).build() + val imageLoader = FavIconImageLoader.get(context) - SubcomposeAsyncImage( - model = imageRequest, - contentDescription = contentDescription, - modifier = Modifier.matchParentSize(), - contentScale = contentScale, - imageLoader = imageLoader, - error = { PlaceHolderIcon() }, - loading = { PlaceHolderIcon() } - ) + SubcomposeAsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize(), + contentScale = contentScale, + imageLoader = imageLoader, + error = { PlaceHolderIcon() }, + loading = { PlaceHolderIcon() } + ) + } else { + AsyncImage( + modifier = Modifier.matchParentSize(), + url = url, + contentDescription = contentDescription, + backgroundColor = null, + ) + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt index af4a33a27..5e6150cf0 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt @@ -30,8 +30,8 @@ import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult import coil3.getExtra -import coil3.network.CacheResponse import coil3.network.CacheStrategy +import coil3.network.ConnectivityChecker import coil3.network.HttpException import coil3.network.NetworkClient import coil3.network.NetworkFetcher @@ -44,8 +44,14 @@ import coil3.request.Options import coil3.util.MimeTypeMap import com.fleeksoft.ksoup.Ksoup import com.fleeksoft.ksoup.nodes.Document -import com.fleeksoft.ksoup.ported.BufferReader -import okio.Buffer +import com.fleeksoft.ksoup.parseSource +import kotlin.math.min +import kotlinx.io.Buffer +import kotlinx.io.EOFException +import kotlinx.io.RawSource +import kotlinx.io.Source +import kotlinx.io.UnsafeIoApi +import kotlinx.io.unsafe.UnsafeBufferOperations import okio.FileSystem import okio.IOException @@ -68,36 +74,31 @@ class FavIconFetcher( val snapshot = readFromDiskCache() try { // Fast path: fetch the fav icon from the disk cache without performing a network request. - var output: CacheStrategy.Output? = null + var output: CacheStrategy.ReadResult? = null if (snapshot != null) { var cacheResponse = snapshot.toCacheResponse() if (cacheResponse != null) { - val input = CacheStrategy.Input(cacheResponse, newRequest(), options) - output = cacheStrategy.value.compute(input) - cacheResponse = output.cacheResponse + output = cacheStrategy.value.read(cacheResponse, newRequest(), options) + cacheResponse = output.response } if (cacheResponse != null) { return SourceFetchResult( source = snapshot.toImageSource(), - mimeType = getMimeType(url, cacheResponse.responseHeaders[CONTENT_TYPE]), + mimeType = getMimeType(url, cacheResponse.headers[CONTENT_TYPE]), dataSource = DataSource.DISK, ) } } // Slow path: fetch the fav icon by parsing response HTML - val networkRequest = output?.networkRequest ?: newRequest() + val networkRequest = output?.request ?: newRequest() return executeNetworkRequest(networkRequest) { response -> // Write the response to the disk cache then open a new snapshot. val responseBody = checkNotNull(response.body) { "body == null" } val responseBodyBuffer = responseBody.readBuffer() val document = - Ksoup.parse( - bufferReader = BufferReader(responseBodyBuffer), - baseUri = url, - charsetName = null - ) + Ksoup.parseSource(source = responseBodyBuffer, baseUri = url, charsetName = null) val favIconUrl = parseFaviconUrl(document) ?: fallbackFaviconUrl(url) return@executeNetworkRequest networkFetcher(favIconUrl).fetch() @@ -207,9 +208,9 @@ class FavIconFetcher( return contentType?.substringBefore(';') } - private fun DiskCache.Snapshot.toCacheResponse(): CacheResponse? { + private fun DiskCache.Snapshot.toCacheResponse(): NetworkResponse? { return try { - fileSystem.read(metadata) { CacheResponse(this) } + fileSystem.read(metadata) { NetworkResponse(body = NetworkResponseBody(this)) } } catch (_: IOException) { // If we can't parse the metadata, ignore this entry. null @@ -233,10 +234,10 @@ class FavIconFetcher( } catch (_: Exception) {} } - private suspend fun NetworkResponseBody.readBuffer(): Buffer = use { body -> - val buffer = Buffer() + private suspend fun NetworkResponseBody.readBuffer(): RawSource = use { body -> + val buffer = okio.Buffer() body.writeTo(buffer) - return buffer + return buffer.asKotlinxIoRawSource() } private val httpMethodKey = Extras.Key(default = HTTP_METHOD_GET) @@ -283,6 +284,7 @@ class FavIconFetcher( networkClient = networkClientLazy, diskCache = diskCacheLazy, cacheStrategy = cacheStrategyLazy, + connectivityChecker = ConnectivityChecker.ONLINE ) } ) @@ -293,3 +295,52 @@ class FavIconFetcher( } } } + +// TODO: Remove after Okio adapters are available in kotlinx-io library +/** + * Returns a [kotlinx.io.RawSource] backed by this [okio.Source]. + * + * Closing one of these sources will also close another one. + */ +public fun okio.Source.asKotlinxIoRawSource(): RawSource = + object : RawSource { + private val buffer = + okio.Buffer() // TODO: optimization - reuse BufferedSource's buffer if possible + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = withOkio2KxIOExceptionMapping { + val readBytes = this@asKotlinxIoRawSource.read(buffer, byteCount) + if (readBytes == -1L) return -1L + + var remaining = readBytes + while (remaining > 0) { + @OptIn(UnsafeIoApi::class) + UnsafeBufferOperations.writeToTail(sink, 1) { data, from, to -> + val toRead = min((to - from).toLong(), remaining).toInt() + val read = buffer.read(data, from, toRead) + check(read != -1) { "Buffer was exhausted before reading $toRead bytes from it." } + remaining -= read + read + } + } + + return readBytes + } + + override fun close() = withOkio2KxIOExceptionMapping { this@asKotlinxIoRawSource.close() } + } + +internal inline fun withOkio2KxIOExceptionMapping(block: () -> T): T { + try { + return block() + } catch ( + bypassIOE: + kotlinx.io.IOException) { // on JVM, kotlinx.io.IOException and okio.IOException are the same + throw bypassIOE + } catch (bypassEOF: kotlinx.io.EOFException) { // see above + throw bypassEOF + } catch (eofe: okio.EOFException) { + throw kotlinx.io.IOException(eofe.message, eofe) + } catch (ioe: okio.IOException) { + throw kotlinx.io.IOException(ioe.message, ioe) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt index 9313edc36..1cf51e0be 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt @@ -20,7 +20,7 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.annotation.ExperimentalCoilApi import coil3.network.CacheStrategy -import coil3.network.ktor2.asNetworkClient +import coil3.network.ktor3.asNetworkClient import io.ktor.client.HttpClient import kotlinx.atomicfu.atomic import kotlinx.atomicfu.updateAndGet @@ -47,7 +47,7 @@ object FavIconImageLoader { add( FavIconFetcher.Factory( networkClient = { HttpClient().asNetworkClient() }, - cacheStrategy = { CacheStrategy() } + cacheStrategy = { CacheStrategy.DEFAULT } ) ) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt index 89a327b63..5d4c8baf7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt @@ -51,9 +51,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -69,7 +69,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -77,7 +76,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.components.ConfirmFeedDeleteDialog import dev.sasikanth.rss.reader.components.Switch -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.feed.FeedEffect import dev.sasikanth.rss.reader.feed.FeedEvent @@ -92,6 +91,7 @@ import dev.sasikanth.rss.reader.share.LocalShareHandler import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.ui.SYSTEM_SCRIM import dev.sasikanth.rss.reader.utils.KeyboardState +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay @@ -126,7 +126,7 @@ fun FeedInfoBottomSheet( .only(WindowInsetsSides.Bottom) .union(WindowInsets.ime.only(WindowInsetsSides.Bottom)) }, - sheetState = SheetState(skipPartiallyExpanded = true, density = LocalDensity.current), + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), scrimColor = SYSTEM_SCRIM ) { Column( @@ -246,8 +246,11 @@ private fun FeedLabelInput( .padding(8.dp) .fillMaxWidth() ) { - FeedFavIcon( - url = feed.homepageLink, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val feedIcon = if (showFeedFavIcon) feed.homepageLink else feed.icon + + FeedIcon( + url = feedIcon, contentDescription = feed.name, modifier = Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)), ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt index 59fe7b5cd..14e204a7c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt @@ -97,6 +97,7 @@ internal fun BottomSheetCollapsedContent( FeedBottomBarItem( badgeCount = source.numberOfUnreadPosts, homePageUrl = source.homepageLink, + feedIconUrl = source.icon, canShowUnreadPostsCount = canShowUnreadPostsCount, onClick = { onSourceClick(source) }, selected = activeSource?.id == source.id diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt index 3e5641d20..6c89f606e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt @@ -35,14 +35,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.BADGE_COUNT_TRIM_LIMIT +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable internal fun FeedBottomBarItem( badgeCount: Long, homePageUrl: String, + feedIconUrl: String, canShowUnreadPostsCount: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -51,8 +53,11 @@ internal fun FeedBottomBarItem( Box(modifier = modifier) { Box(contentAlignment = Alignment.Center) { SelectionIndicator(selected = selected, animationProgress = 1f) - FeedFavIcon( - url = homePageUrl, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val feedIcon = if (showFeedFavIcon) homePageUrl else feedIconUrl + + FeedIcon( + url = feedIcon, contentDescription = null, modifier = Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)).clickable(onClick = onClick) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt index 7b9f0d285..eea52b091 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.BADGE_COUNT_TRIM_LIMIT +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable internal fun FeedGroupBottomBarItem( @@ -57,22 +58,24 @@ internal fun FeedGroupBottomBarItem( .padding(8.dp), contentAlignment = Alignment.Center ) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icons = if (showFeedFavIcon) feedGroup.feedHomepageLinks else feedGroup.feedIconLinks val iconSize = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = feedGroup.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = CircleShape, verticalArrangement = Arrangement.spacedBy(iconSpacing), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt index 9afda1191..3b077ade7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.ui.AppTheme @Composable @@ -103,7 +103,7 @@ internal fun FeedGroupIconGrid( @Composable private fun FeedIcon(icon: String?, iconSize: Dp, iconShape: Shape, modifier: Modifier = Modifier) { if (!icon.isNullOrBlank()) { - FeedFavIcon( + FeedIcon( url = icon, contentDescription = null, modifier = diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt index 13229567c..ff3e78c91 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt @@ -51,6 +51,7 @@ import dev.sasikanth.rss.reader.resources.icons.RadioUnselected import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @OptIn(ExperimentalFoundationApi::class) @Composable @@ -98,15 +99,18 @@ internal fun FeedGroupItem( .padding(8.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icons = if (showFeedFavIcon) feedGroup.feedHomepageLinks else feedGroup.feedIconLinks + val iconSize = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 17.dp } else { 19.dp } val iconSpacing = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 2.dp } else { 0.dp @@ -114,7 +118,7 @@ internal fun FeedGroupItem( FeedGroupIconGrid( modifier = Modifier.requiredSize(36.dp), - icons = feedGroup.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = CircleShape, verticalArrangement = Arrangement.spacedBy(iconSpacing), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt index 03b118bff..b6ca0e891 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt @@ -43,12 +43,13 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.resources.icons.RadioSelected import dev.sasikanth.rss.reader.resources.icons.RadioUnselected import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @OptIn(ExperimentalFoundationApi::class) @Composable @@ -95,8 +96,11 @@ internal fun FeedListItem( ) ) { Row(modifier = Modifier.padding(all = 8.dp), verticalAlignment = Alignment.CenterVertically) { - FeedFavIcon( - url = feed.homepageLink, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icon = if (showFeedFavIcon) feed.homepageLink else feed.icon + + FeedIcon( + url = icon, contentDescription = null, modifier = Modifier.requiredSize(36.dp).clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt index c9b31e424..af92b2997 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search @@ -73,6 +74,7 @@ import dev.sasikanth.rss.reader.components.ContextActionItem import dev.sasikanth.rss.reader.components.ContextActionsBottomBar import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.FeedsViewMode +import dev.sasikanth.rss.reader.core.model.local.SourceType import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter import dev.sasikanth.rss.reader.feeds.ui.BottomSheetExpandedBottomBar @@ -151,14 +153,14 @@ internal fun BottomSheetExpandedContent( ) { val areSelectedFeedsPinned = state.selectedSources.all { it.pinnedAt != null } - val label = + val pinActionLabel = if (areSelectedFeedsPinned) LocalStrings.current.actionUnpin else LocalStrings.current.actionPin ContextActionItem( modifier = Modifier.weight(1f), icon = TwineIcons.Pin, - label = label, + label = pinActionLabel, onClick = { if (areSelectedFeedsPinned) { feedsPresenter.dispatch(FeedsEvent.UnPinSelectedSources) @@ -184,10 +186,23 @@ internal fun BottomSheetExpandedContent( ) if (state.selectedSources.size == 1) { + val editIcon = + if (state.selectedSources.first().sourceType == SourceType.FeedGroup) { + Icons.Filled.Edit + } else { + Icons.Filled.Tune + } + val editLabel = + if (state.selectedSources.first().sourceType == SourceType.FeedGroup) { + LocalStrings.current.edit + } else { + LocalStrings.current.settings + } + ContextActionItem( modifier = Modifier.weight(1f), - icon = Icons.Filled.Edit, - label = LocalStrings.current.edit, + icon = editIcon, + label = editLabel, onClick = { feedsPresenter.dispatch( FeedsEvent.OnEditSourceClicked(state.selectedSources.first()) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/groupselection/ui/GroupSelectionSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/groupselection/ui/GroupSelectionSheet.kt index 057cb5f8d..f8dd5ab66 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/groupselection/ui/GroupSelectionSheet.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/groupselection/ui/GroupSelectionSheet.kt @@ -39,8 +39,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import app.cash.paging.compose.collectAsLazyPagingItems import dev.sasikanth.rss.reader.components.Button @@ -79,7 +78,7 @@ fun GroupSelectionSheet(presenter: GroupSelectionPresenter, modifier: Modifier = .only(WindowInsetsSides.Bottom) .union(WindowInsets.ime.only(WindowInsetsSides.Bottom)) }, - sheetState = SheetState(skipPartiallyExpanded = true, density = LocalDensity.current), + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), scrimColor = SYSTEM_SCRIM ) { val state by presenter.state.collectAsState() diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt index 4522fff8d..ac8bbf04a 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt @@ -13,11 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@file:OptIn(ExperimentalMaterialApi::class) package dev.sasikanth.rss.reader.home -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.SheetValue import app.cash.paging.cachedIn import app.cash.paging.createPager @@ -50,7 +48,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Instant @@ -207,17 +220,7 @@ class HomePresenter( private fun markPostsAsRead(source: Source?) { coroutineScope.launch { - val postsAfter = - when (_state.value.postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val postsAfter = getPostsAfter(_state.value.postsType) when (source) { is Feed -> { @@ -279,17 +282,7 @@ class HomePresenter( Pair(activeSource, postsType) } .flatMapLatest { (activeSource, postsType) -> - val postsAfter = - when (postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val postsAfter = getPostsAfter(postsType) rssRepository.hasUnreadPostsInSource( sourceId = activeSource?.id, @@ -315,36 +308,26 @@ class HomePresenter( } } .flatMapLatest { (activeSource, postsType) -> - val unreadOnly = - when (postsType) { - PostsType.ALL, - PostsType.TODAY, - PostsType.LAST_24_HOURS -> null - PostsType.UNREAD -> true - } - - val postsAfter = - when (postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val unreadOnly = getUnreadOnly(postsType) + val postsAfter = getPostsAfter(postsType) loadFeaturedPostsItems( activeSource = activeSource, unreadOnly = unreadOnly, postsAfter = postsAfter ) - .onEach { featuredPosts -> + .transformLatest { featuredPosts -> _state.update { it.copy(featuredPosts = featuredPosts.toImmutableList()) } + + emit(NTuple4(activeSource, postsAfter, unreadOnly, featuredPosts)) + + val postsWithSeedColors = calculateSeedColors(featuredPosts) + + _state.update { it.copy(featuredPosts = postsWithSeedColors) } + } + .distinctUntilChangedBy { (_, _, _, featuredPosts) -> + featuredPosts.map { it.postWithMetadata.id } } - .distinctUntilChangedBy { it.map { featuredPost -> featuredPost.postWithMetadata.id } } - .map { featuredPosts -> NTuple4(activeSource, postsAfter, unreadOnly, featuredPosts) } } .onEach { (activeSource, postsAfter, unreadOnly, featuredPosts) -> val posts = @@ -361,22 +344,41 @@ class HomePresenter( _state.update { it.copy(posts = posts, loadingState = HomeLoadingState.Idle) } } - .onEach { (_, _, _, featuredPosts) -> - val featuredPostsWithSeedColor = - featuredPosts.map { featuredPost -> - val seedColor = - withContext(dispatchersProvider.default) { - seedColorExtractor.calculateSeedColor(featuredPost.postWithMetadata.imageUrl) - } - - featuredPost.copy(seedColor = seedColor) - } - - _state.update { it.copy(featuredPosts = featuredPostsWithSeedColor.toImmutableList()) } - } .launchIn(coroutineScope) } + private suspend fun calculateSeedColors(featuredPosts: List) = + featuredPosts + .map { post -> + post.copy( + seedColor = + withContext(dispatchersProvider.default) { + seedColorExtractor.calculateSeedColor(post.postWithMetadata.imageUrl) + } + ) + } + .toImmutableList() + + private fun getPostsAfter(postsType: PostsType) = + when (postsType) { + PostsType.ALL, + PostsType.UNREAD -> Instant.DISTANT_PAST + PostsType.TODAY -> { + getTodayStartInstant() + } + PostsType.LAST_24_HOURS -> { + getLast24HourStart() + } + } + + private fun getUnreadOnly(postsType: PostsType) = + when (postsType) { + PostsType.ALL, + PostsType.TODAY, + PostsType.LAST_24_HOURS -> null + PostsType.UNREAD -> true + } + private fun loadFeaturedPostsItems( activeSource: Source?, unreadOnly: Boolean?, @@ -388,15 +390,11 @@ class HomePresenter( unreadOnly = unreadOnly, after = postsAfter ) - .map { featuredPosts -> + .mapLatest { featuredPosts -> featuredPosts.map { postWithMetadata -> - val seedColor = - withContext(dispatchersProvider.default) { - seedColorExtractor.cached(postWithMetadata.imageUrl) - } FeaturedPostItem( postWithMetadata = postWithMetadata, - seedColor = seedColor, + seedColor = null, ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt index 6921c399c..5e7eab1f8 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt @@ -186,26 +186,25 @@ internal fun FeaturedSection( val featuredPost = featuredPosts.getOrNull(page) if (featuredPost != null) { Box { - if (useDarkTheme) { - FeaturedSectionBackground( - modifier = - Modifier.matchParentSize().layout { measurable, constraints -> - val topPadding = contentPadding.calculateTopPadding().roundToPx() - val horizontalContentPadding = - contentPadding.calculateStartPadding(layoutDirection) + - contentPadding.calculateEndPadding(layoutDirection) - val fullWidth = constraints.maxWidth + horizontalContentPadding.roundToPx() - val placeable = measurable.measure(constraints.copy(maxWidth = fullWidth)) + FeaturedSectionBackground( + modifier = + Modifier.matchParentSize().layout { measurable, constraints -> + val topPadding = contentPadding.calculateTopPadding().roundToPx() + val horizontalContentPadding = + contentPadding.calculateStartPadding(layoutDirection) + + contentPadding.calculateEndPadding(layoutDirection) + val fullWidth = constraints.maxWidth + horizontalContentPadding.roundToPx() + val placeable = measurable.measure(constraints.copy(maxWidth = fullWidth)) - layout(placeable.width, placeable.height) { - placeable.place(0, topPadding.unaryMinus()) - } - }, - state = pagerState, - page = page, - featuredPost = featuredPost, - ) - } + layout(placeable.width, placeable.height) { + placeable.place(0, topPadding.unaryMinus()) + } + }, + state = pagerState, + page = page, + featuredPost = featuredPost, + useDarkTheme = useDarkTheme, + ) val postWithMetadata = featuredPost.postWithMetadata FeaturedPostItem( @@ -231,21 +230,27 @@ private fun FeaturedSectionBackground( state: PagerState, page: Int, featuredPost: FeaturedPostItem, + useDarkTheme: Boolean, modifier: Modifier = Modifier, ) { Box(modifier) { val gradientOverlayModifier = Modifier.drawWithCache { + val gradientColor = if (useDarkTheme) Color.Black else Color.White val radialGradient = Brush.radialGradient( colors = - listOf(Color.Black, Color.Black.copy(alpha = 0.0f), Color.Black.copy(alpha = 0.0f)), + listOf( + gradientColor, + gradientColor.copy(alpha = 0.0f), + gradientColor.copy(alpha = 0.0f) + ), center = Offset(x = this.size.width, y = 40f) ) val linearGradient = Brush.verticalGradient( - colors = listOf(Color.Black, Color.Black.copy(alpha = 0.0f)), + colors = listOf(gradientColor, gradientColor.copy(alpha = 0.0f)), ) onDrawWithContent { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt index 4ad8a0d23..fe298f868 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt @@ -62,7 +62,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.components.DropdownMenu import dev.sasikanth.rss.reader.components.DropdownMenuItem -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.PostsType @@ -73,6 +73,7 @@ import dev.sasikanth.rss.reader.resources.icons.Tune import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting private const val APP_BAR_OPAQUE_THRESHOLD = 200f @@ -189,24 +190,26 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { Spacer(Modifier.requiredWidth(16.dp)) } + val showFeedFavIcon = LocalShowFeedFavIconSetting.current when (source) { is FeedGroup -> { + val icons = if (showFeedFavIcon) source.feedHomepageLinks else source.feedIconLinks val iconSize = - if (source.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (source.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = source.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = RoundedCornerShape(percent = 30), horizontalArrangement = Arrangement.spacedBy(iconSpacing), @@ -214,8 +217,9 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { ) } is Feed -> { - FeedFavIcon( - url = source.homepageLink, + val icon = if (showFeedFavIcon) source.homepageLink else source.icon + FeedIcon( + url = icon, contentDescription = null, modifier = Modifier.clip(MaterialTheme.shapes.small).requiredSize(24.dp) ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt index 1311a6e4d..688edc70c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt @@ -140,12 +140,14 @@ fun PostListItem( onPostCommentsClick: () -> Unit, onPostSourceClick: () -> Unit, togglePostReadClick: () -> Unit, + modifier: Modifier = Modifier, reduceReadItemAlpha: Boolean = false, postMetadataConfig: PostMetadataConfig = PostMetadataConfig.DEFAULT, ) { Column( modifier = - Modifier.clickable(onClick = onClick) + Modifier.then(modifier) + .clickable(onClick = onClick) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(postListPadding) .alpha( diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt index 46706dcd6..fed2609cc 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt @@ -142,7 +142,8 @@ class ReaderPresenter( ) } - if (feed.alwaysFetchSourceArticle) { + val hasContent = post.description.isNotBlank() || post.rawContent.isNullOrBlank().not() + if (feed.alwaysFetchSourceArticle || hasContent.not()) { loadSourceArticle() } else { loadRssContent() diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt index 4439d3dea..44d7c51dd 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt @@ -29,6 +29,10 @@ sealed interface SettingsEvent { data class ToggleShowReaderView(val value: Boolean) : SettingsEvent + data class ToggleAutoSync(val value: Boolean) : SettingsEvent + + data class ToggleShowFeedFavIcon(val value: Boolean) : SettingsEvent + data object AboutClicked : SettingsEvent data object ImportOpmlClicked : SettingsEvent diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt index 458315f32..4a2ef8c59 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt @@ -110,21 +110,24 @@ class SettingsPresenter( settingsRepository.postsDeletionPeriod, settingsRepository.showReaderView, settingsRepository.appThemeMode, - rssRepository.hasFeeds() + settingsRepository.enableAutoSync, + settingsRepository.showFeedFavIcon ) { browserType, showUnreadPostsCount, postsDeletionPeriod, showReaderView, appThemeMode, - hasFeeds -> + enableAutoSync, + showFeedFavIcon -> Settings( browserType = browserType, showUnreadPostsCount = showUnreadPostsCount, - hasFeeds = hasFeeds, postsDeletionPeriod = postsDeletionPeriod, showReaderView = showReaderView, appThemeMode = appThemeMode, + enableAutoSync = enableAutoSync, + showFeedFavIcon = showFeedFavIcon, ) } .onEach { settings -> @@ -132,15 +135,21 @@ class SettingsPresenter( it.copy( browserType = settings.browserType, showUnreadPostsCount = settings.showUnreadPostsCount, - hasFeeds = settings.hasFeeds, postsDeletionPeriod = settings.postsDeletionPeriod, showReaderView = settings.showReaderView, appThemeMode = settings.appThemeMode, + enableAutoSync = settings.enableAutoSync, + showFeedFavIcon = settings.showFeedFavIcon, ) } } .launchIn(coroutineScope) + rssRepository + .hasFeeds() + .onEach { hasFeeds -> _state.update { it.copy(hasFeeds = hasFeeds) } } + .launchIn(coroutineScope) + opmlManager.result .onEach { result -> _state.update { it.copy(opmlResult = result) } } .launchIn(coroutineScope) @@ -154,6 +163,8 @@ class SettingsPresenter( is SettingsEvent.UpdateBrowserType -> updateBrowserType(event.browserType) is SettingsEvent.ToggleShowUnreadPostsCount -> toggleShowUnreadPostsCount(event.value) is SettingsEvent.ToggleShowReaderView -> toggleShowReaderView(event.value) + is SettingsEvent.ToggleAutoSync -> toggleAutoSync(event.value) + is SettingsEvent.ToggleShowFeedFavIcon -> toggleShowFeedFavIcon(event.value) SettingsEvent.AboutClicked -> { // no-op } @@ -165,6 +176,14 @@ class SettingsPresenter( } } + private fun toggleShowFeedFavIcon(value: Boolean) { + coroutineScope.launch { settingsRepository.toggleShowFeedFavIcon(value) } + } + + private fun toggleAutoSync(value: Boolean) { + coroutineScope.launch { settingsRepository.toggleAutoSync(value) } + } + private fun onAppThemeModeChanged(appThemeMode: AppThemeMode) { coroutineScope.launch { settingsRepository.updateAppTheme(appThemeMode) } } @@ -206,8 +225,9 @@ class SettingsPresenter( private data class Settings( val browserType: BrowserType, val showUnreadPostsCount: Boolean, - val hasFeeds: Boolean, val postsDeletionPeriod: Period, val showReaderView: Boolean, val appThemeMode: AppThemeMode, + val enableAutoSync: Boolean, + val showFeedFavIcon: Boolean, ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt index 143b80458..68a40b4bb 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt @@ -32,6 +32,8 @@ internal data class SettingsState( val postsDeletionPeriod: Period?, val showReaderView: Boolean, val appThemeMode: AppThemeMode, + val enableAutoSync: Boolean, + val showFeedFavIcon: Boolean, ) { companion object { @@ -46,6 +48,8 @@ internal data class SettingsState( postsDeletionPeriod = null, showReaderView = false, appThemeMode = AppThemeMode.Auto, + enableAutoSync = true, + showFeedFavIcon = true, ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt index 73712ee3b..254f57e32 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt @@ -17,7 +17,6 @@ package dev.sasikanth.rss.reader.settings.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -100,11 +99,6 @@ internal fun SettingsScreen( val state by settingsPresenter.state.collectAsState() val layoutDirection = LocalLayoutDirection.current val linkHandler = LocalLinkHandler.current - val isSystemInDarkMode = - when (state.appThemeMode) { - AppThemeMode.Dark -> true - else -> isSystemInDarkTheme() - } Scaffold( modifier = modifier, @@ -221,6 +215,28 @@ internal fun SettingsScreen( item { Divider(24.dp) } + item { + AutoSyncSettingItem( + enableAutoSync = state.enableAutoSync, + onValueChanged = { newValue -> + settingsPresenter.dispatch(SettingsEvent.ToggleAutoSync(newValue)) + } + ) + } + + item { Divider(24.dp) } + + item { + ShowFeedFavIconSettingItem( + showFeedFavIcon = state.showFeedFavIcon, + onValueChanged = { newValue -> + settingsPresenter.dispatch(SettingsEvent.ToggleShowFeedFavIcon(newValue)) + } + ) + } + + item { Divider(24.dp) } + item { PostsDeletionPeriodSettingItem( postsDeletionPeriod = state.postsDeletionPeriod, @@ -399,6 +415,83 @@ private fun PostsDeletionPeriodSettingItem( } } +@Composable +private fun ShowFeedFavIconSettingItem( + showFeedFavIcon: Boolean, + onValueChanged: (Boolean) -> Unit +) { + var checked by remember(showFeedFavIcon) { mutableStateOf(showFeedFavIcon) } + Box( + modifier = + Modifier.clickable { + checked = !checked + onValueChanged(!showFeedFavIcon) + } + ) { + Row( + modifier = Modifier.padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + LocalStrings.current.showFeedFavIconTitle, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh + ) + Text( + LocalStrings.current.showFeedFavIconDesc, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + + Spacer(Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { checked -> onValueChanged(checked) }, + ) + } + } +} + +@Composable +private fun AutoSyncSettingItem(enableAutoSync: Boolean, onValueChanged: (Boolean) -> Unit) { + var checked by remember(enableAutoSync) { mutableStateOf(enableAutoSync) } + Box( + modifier = + Modifier.clickable { + checked = !checked + onValueChanged(!enableAutoSync) + } + ) { + Row( + modifier = Modifier.padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + LocalStrings.current.enableAutoSyncTitle, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh + ) + Text( + LocalStrings.current.enableAutoSyncDesc, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + + Spacer(Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { checked -> onValueChanged(checked) }, + ) + } + } +} + @Composable private fun UnreadPostsCountSettingItem( showUnreadCountEnabled: Boolean, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt new file mode 100644 index 000000000..106d904ba --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Sasikanth Miriyampalli + * + * 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 + * + * http://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. + */ + +package dev.sasikanth.rss.reader.utils + +import androidx.compose.runtime.compositionLocalOf + +internal val LocalShowFeedFavIconSetting = compositionLocalOf { true } diff --git a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt index 0d3324697..89f08a743 100644 --- a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt +++ b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt @@ -96,7 +96,7 @@ class FeedParserTest { link = "https://example.com/post-with-relative-image", description = "Relative image post description.", rawContent = "Relative image post description.", - imageUrl = "https://example.com/relative-media-url", + imageUrl = "http://example.com/relative-media-url", date = 1685005200000, commentsLink = null ), @@ -228,7 +228,7 @@ class FeedParserTest {

Post summary with an image.

""" .trimIndent(), - imageUrl = "https://example.com/resources/image.jpg", + imageUrl = "http://example.com/resources/image.jpg", date = 1685008800000, commentsLink = null ),