From 6adbc560a21ca72b0d5ef8074b7a778d3dc90687 Mon Sep 17 00:00:00 2001 From: Michael Usher Date: Sun, 25 Aug 2024 17:31:37 +1000 Subject: [PATCH 1/4] Add theme switcher and a simple black and white theme --- .../java/com/readrops/app/MainActivity.kt | 8 +- .../app/more/preferences/PreferencesScreen.kt | 11 +++ .../preferences/PreferencesScreenModel.kt | 9 +- .../java/com/readrops/app/util/Preferences.kt | 14 ++- .../java/com/readrops/app/util/theme/Color.kt | 65 ++++++++++++++ .../java/com/readrops/app/util/theme/Theme.kt | 87 +++++++++++++++++-- app/src/main/res/values-de/strings.xml | 5 +- app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-in/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-nb-rNO/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 15 files changed, 210 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/readrops/app/MainActivity.kt b/app/src/main/java/com/readrops/app/MainActivity.kt index 8717996b2..775d7bb2d 100644 --- a/app/src/main/java/com/readrops/app/MainActivity.kt +++ b/app/src/main/java/com/readrops/app/MainActivity.kt @@ -55,15 +55,21 @@ class MainActivity : ComponentActivity(), KoinComponent { val initialUseDarkTheme = runBlocking { useDarkTheme(preferences.theme.flow.first(), darkFlag) } + val initialColourScheme = runBlocking { + preferences.themeColourScheme.flow.first() + } setContent { KoinAndroidContext { val useDarkTheme by preferences.theme.flow .map { mode -> useDarkTheme(mode, darkFlag) } .collectAsState(initial = initialUseDarkTheme) + val themeColourScheme by preferences.themeColourScheme.flow + .collectAsState(initial = initialColourScheme) ReadropsTheme( - useDarkTheme = useDarkTheme + useDarkTheme = useDarkTheme, + themeColourScheme = themeColourScheme ) { val navigationBarElevation = NavigationBarDefaults.Elevation diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt index 6d6a7a653..5bd1e6a9f 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt @@ -99,6 +99,17 @@ class PreferencesScreen : AndroidScreen() { onValueChange = {} ) + ListPreferenceWidget( + preference = loadedState.themeColourScheme.second, + selectedKey = loadedState.themeColourScheme.first, + entries = mapOf( + "readrops" to stringResource(id = R.string.theme_readrops), + "blackwhite" to stringResource(id = R.string.theme_blackwhite), + ), + title = stringResource(id = R.string.theme_color_scheme), + onValueChange = {} + ) + ListPreferenceWidget( preference = loadedState.backgroundSyncPref.second, selectedKey = loadedState.backgroundSyncPref.first, diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt index 3d0432233..d488a4309 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt @@ -25,7 +25,8 @@ class PreferencesScreenModel( preferences.scrollRead.flow, preferences.hideReadFeeds.flow, preferences.openLinksWith.flow, - preferences.timelineItemSize.flow + preferences.timelineItemSize.flow, + preferences.themeColourScheme.flow ) combine( @@ -37,7 +38,8 @@ class PreferencesScreenModel( scrollReadPref = (list[2] as Boolean) to preferences.scrollRead, hideReadFeeds = (list[3] as Boolean) to preferences.hideReadFeeds, openLinksWith = (list[4] as String) to preferences.openLinksWith, - timelineItemSize = (list[5] as String) to preferences.timelineItemSize + timelineItemSize = (list[5] as String) to preferences.timelineItemSize, + themeColourScheme = (list[6] as String) to preferences.themeColourScheme ) }.collect { theme -> mutableState.update { theme } @@ -57,7 +59,8 @@ sealed class PreferencesScreenState { val scrollReadPref: PreferenceState, val hideReadFeeds: PreferenceState, val openLinksWith: PreferenceState, - val timelineItemSize: PreferenceState + val timelineItemSize: PreferenceState, + val themeColourScheme: PreferenceState ) : PreferencesScreenState() } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Preferences.kt b/app/src/main/java/com/readrops/app/util/Preferences.kt index 81774c6fc..1d432955d 100644 --- a/app/src/main/java/com/readrops/app/util/Preferences.kt +++ b/app/src/main/java/com/readrops/app/util/Preferences.kt @@ -26,10 +26,16 @@ class Preferences( ) { val theme = Preference( - dataStore = dataStore, - key = stringPreferencesKey("theme"), - default = "system" - ) + dataStore = dataStore, + key = stringPreferencesKey("theme"), + default = "system" + ) + + val themeColourScheme = Preference( + dataStore = dataStore, + key = stringPreferencesKey("theme_color_scheme"), + default = "readrops" + ) val backgroundSynchronization = Preference( dataStore = dataStore, diff --git a/app/src/main/java/com/readrops/app/util/theme/Color.kt b/app/src/main/java/com/readrops/app/util/theme/Color.kt index 286ad61da..30d0e56d0 100644 --- a/app/src/main/java/com/readrops/app/util/theme/Color.kt +++ b/app/src/main/java/com/readrops/app/util/theme/Color.kt @@ -2,6 +2,7 @@ package com.readrops.app.util.theme import androidx.compose.ui.graphics.Color +// Material Design Light val md_theme_light_primary = Color(0xFF0062A2) val md_theme_light_onPrimary = Color(0xFFFFFFFF) val md_theme_light_primaryContainer = Color(0xFFD1E4FF) @@ -33,6 +34,7 @@ val md_theme_light_surfaceTint = Color(0xFF0062A2) val md_theme_light_outlineVariant = Color(0xFFC3C7CF) val md_theme_light_scrim = Color(0xFF000000) +// Material Design Dark val md_theme_dark_primary = Color(0xFF9DCAFF) val md_theme_dark_onPrimary = Color(0xFF003257) val md_theme_dark_primaryContainer = Color(0xFF00497C) @@ -64,5 +66,68 @@ val md_theme_dark_surfaceTint = Color(0xFF9DCAFF) val md_theme_dark_outlineVariant = Color(0xFF42474E) val md_theme_dark_scrim = Color(0xFF000000) +// Black & White Light +val bw_theme_light_primary = Color(0xFF000000) +val bw_theme_light_onPrimary = Color(0xFFFFFFFF) +val bw_theme_light_primaryContainer = Color(0xFF262626) +val bw_theme_light_onPrimaryContainer = Color(0xFFB1B1B1) +val bw_theme_light_secondary = Color(0xFF5E5E5E) +val bw_theme_light_onSecondary = Color(0xFFFFFFFF) +val bw_theme_light_secondaryContainer = Color(0xFFE6E6E6) +val bw_theme_light_onSecondaryContainer = Color(0xFF4A4A4A) +val bw_theme_light_tertiary = Color(0xFF000000) +val bw_theme_light_onTertiary = Color(0xFFFFFFFF) +val bw_theme_light_tertiaryContainer = Color(0xFF262626) +val bw_theme_light_onTertiaryContainer = Color(0xFFB1B1B1) +val bw_theme_light_error = Color(0xFF5E5E5E) +val bw_theme_light_onError = Color(0xFFFFFFFF) +val bw_theme_light_errorContainer = Color(0xFFE6E6E6) +val bw_theme_light_onErrorContainer = Color(0xFF000000) +val bw_theme_light_background = Color(0xFFF9F9F9) +val bw_theme_light_onBackground = Color(0xFF1B1B1B) +val bw_theme_light_surface = Color(0xFFF9F9F9) +val bw_theme_light_onSurface = Color(0xFF1B1B1B) +val bw_theme_light_surfaceVariant = Color(0xFFFFFFFF) +val bw_theme_light_onSurfaceVariant = Color(0xFF4C4546) +val bw_theme_light_outline = Color(0xFF7E7576) +val bw_theme_light_inverseOnSurface = Color(0xFFF1F1F1) +val bw_theme_light_inverseSurface = Color(0xFF303030) +val bw_theme_light_inversePrimary = Color(0xFFC6C6C6) +val bw_theme_light_shadow = Color(0xFF4C4546) +val bw_theme_light_surfaceTint = Color(0xFFF3F3F3) +val bw_theme_light_outlineVariant = Color(0xFFF3F3F3) +val bw_theme_light_scrim = Color(0xFF000000) + +// Black & White Dark +val bw_theme_dark_primary = Color(0xFFC6C6C6) +val bw_theme_dark_onPrimary = Color(0xFF303030) +val bw_theme_dark_onPrimaryContainer = Color(0xFF969696) +val bw_theme_dark_primaryContainer = Color(0xFF000000) +val bw_theme_dark_secondary = Color(0xFFC6C6C6) +val bw_theme_dark_onSecondary = Color(0xFF303030) +val bw_theme_dark_secondaryContainer = Color(0xFF3D3D3D) +val bw_theme_dark_onSecondaryContainer = Color(0xFFD1D1D1) +val bw_theme_dark_tertiary = Color(0xFFC6C6C6) +val bw_theme_dark_onTertiary = Color(0xFF303030) +val bw_theme_dark_tertiaryContainer = Color(0xFF000000) +val bw_theme_dark_onTertiaryContainer = Color(0xFFFFFFFF) +val bw_theme_dark_error = Color(0xFF5E5E5E) +val bw_theme_dark_onError = Color(0xFF000000) +val bw_theme_dark_errorContainer = Color(0xFF0A0A0A) +val bw_theme_dark_onErrorContainer = Color(0xFFFFFFFF) +val bw_theme_dark_background = Color(0xFF131313) +val bw_theme_dark_onBackground = Color(0xFFFFFFFF) +val bw_theme_dark_surface = Color(0xFF131313) +val bw_theme_dark_onSurface = Color(0xFFE2E2E2) +val bw_theme_dark_surfaceVariant = Color(0xFF131313) +val bw_theme_dark_onSurfaceVariant = Color(0xFFCFC4C5) +val bw_theme_dark_outline = Color(0xFF988E90) +val bw_theme_dark_inverseSurface = Color(0xFFE2E2E2) +val bw_theme_dark_inverseOnSurface = Color(0xFF303030) +val bw_theme_dark_inversePrimary = Color(0xFFFFFFFF) +val bw_theme_dark_shadow = Color(0xFF4C4546) +val bw_theme_dark_surfaceTint = Color(0xFF0A0A0A) +val bw_theme_dark_outlineVariant = Color(0xFF4C4546) +val bw_theme_dark_scrim = Color(0xFF000000) val seed = Color(0xFF0072BC) diff --git a/app/src/main/java/com/readrops/app/util/theme/Theme.kt b/app/src/main/java/com/readrops/app/util/theme/Theme.kt index 908a92a40..f72b80054 100644 --- a/app/src/main/java/com/readrops/app/util/theme/Theme.kt +++ b/app/src/main/java/com/readrops/app/util/theme/Theme.kt @@ -5,7 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable - +import com.readrops.app.R private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -72,15 +72,92 @@ private val DarkColors = darkColorScheme( scrim = md_theme_dark_scrim, ) +private val BlackWhiteLightColors = lightColorScheme( + primary = bw_theme_light_primary, + onPrimary = bw_theme_light_onPrimary, + primaryContainer = bw_theme_light_primaryContainer, + onPrimaryContainer = bw_theme_light_onPrimaryContainer, + secondary = bw_theme_light_secondary, + onSecondary = bw_theme_light_onSecondary, + secondaryContainer = bw_theme_light_secondaryContainer, + onSecondaryContainer = bw_theme_light_onSecondaryContainer, + tertiary = bw_theme_light_tertiary, + onTertiary = bw_theme_light_onTertiary, + tertiaryContainer = bw_theme_light_tertiaryContainer, + onTertiaryContainer = bw_theme_light_onTertiaryContainer, + error = bw_theme_light_error, + errorContainer = bw_theme_light_errorContainer, + onError = bw_theme_light_onError, + onErrorContainer = bw_theme_light_onErrorContainer, + background = bw_theme_light_background, + onBackground = bw_theme_light_onBackground, + surface = bw_theme_light_surface, + onSurface = bw_theme_light_onSurface, + surfaceVariant = bw_theme_light_surfaceVariant, + onSurfaceVariant = bw_theme_light_onSurfaceVariant, + outline = bw_theme_light_outline, + inverseOnSurface = bw_theme_light_inverseOnSurface, + inverseSurface = bw_theme_light_inverseSurface, + inversePrimary = bw_theme_light_inversePrimary, + surfaceTint = bw_theme_light_surfaceTint, + outlineVariant = bw_theme_light_outlineVariant, + scrim = bw_theme_light_scrim, +) + +private val BlackWhiteDarkColors = lightColorScheme( + primary = bw_theme_dark_primary, + onPrimary = bw_theme_dark_onPrimary, + primaryContainer = bw_theme_dark_primaryContainer, + onPrimaryContainer = bw_theme_dark_onPrimaryContainer, + secondary = bw_theme_dark_secondary, + onSecondary = bw_theme_dark_onSecondary, + secondaryContainer = bw_theme_dark_secondaryContainer, + onSecondaryContainer = bw_theme_dark_onSecondaryContainer, + tertiary = bw_theme_dark_tertiary, + onTertiary = bw_theme_dark_onTertiary, + tertiaryContainer = bw_theme_dark_tertiaryContainer, + onTertiaryContainer = bw_theme_dark_onTertiaryContainer, + error = bw_theme_dark_error, + errorContainer = bw_theme_dark_errorContainer, + onError = bw_theme_dark_onError, + onErrorContainer = bw_theme_dark_onErrorContainer, + background = bw_theme_dark_background, + onBackground = bw_theme_dark_onBackground, + surface = bw_theme_dark_surface, + onSurface = bw_theme_dark_onSurface, + surfaceVariant = bw_theme_dark_surfaceVariant, + onSurfaceVariant = bw_theme_dark_onSurfaceVariant, + outline = bw_theme_dark_outline, + inverseOnSurface = bw_theme_dark_inverseOnSurface, + inverseSurface = bw_theme_dark_inverseSurface, + inversePrimary = bw_theme_dark_inversePrimary, + surfaceTint = bw_theme_dark_surfaceTint, + outlineVariant = bw_theme_dark_outlineVariant, + scrim = bw_theme_dark_scrim, +) + @Composable fun ReadropsTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), + themeColourScheme: String = "readrops", content: @Composable () -> Unit ) { - val colors = if (!useDarkTheme) { - LightColors - } else { - DarkColors + + + val colors = when(themeColourScheme) { + "blackwhite" -> { + if (!useDarkTheme) { + BlackWhiteLightColors + } else { + BlackWhiteDarkColors + } + } else -> { + if (!useDarkTheme) { + LightColors + } else { + DarkColors + } + } } MaterialTheme( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c0a4469cb..9930fea39 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -28,7 +28,10 @@ Exportieren von Feeds und Ordnern Dunkel Hell - Farbschema + Thema + Farbschema + Readrops + Black White Bild teilen Bild herunterladen Bildoptionen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6abac13db..e87c4badc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -112,6 +112,9 @@ Descargar imagen Compartir imagen Tema + Bandera + Readrops + Black White 1 hora 6 horas Para que se muestren las notificaciones es necesario que la sincronización automática esté activada. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 99fcf5678..5f899b81c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -107,6 +107,9 @@ Télécharger l\'image Partager l\'image Thème + Schéma de couleurs + Readrops + Black White Clair Sombre Export des flux et dossiers diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 0ab0d5d1f..79ae7ec97 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -107,6 +107,9 @@ Unduh gambar Bagikan gambar Tema + Skema Warna + Readrops + Black White Terang Gelap Ekspor feed dan folder diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7a5f00d69..897728684 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,6 +30,9 @@ Scuro Chiaro Tema + Combinazione di colori + Readrops + Black White Condividi l\'immagine Scarica l\'immagine Opzioni dell\'immagine diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 1a7951c49..5f83720d0 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -6,6 +6,9 @@ Mørk Lys Drakt + Fargevalg + Readrops + Black White Del bilde Last ned bilde Bildevalg diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7055fdb5b..fbf38fcef 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -35,4 +35,8 @@ Feed verwijderen? Laden Geen map + Thema + Kleurenschema + Readrops + Black White \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 25ee1fb55..43181f8df 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -57,6 +57,9 @@ Baixar a imagem Compartilhar a imagem Tema + Esquema de cores + Readrops + Black White Claro Escuro Exportar Feeds e pastas diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12f19be4c..155031a6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,9 @@ Download image Share image Theme + Color Scheme + Readrops + Black White Light Dark Export feeds and folders From 6f0e049a0c589631670c6da5ad9c553fea3c89e5 Mon Sep 17 00:00:00 2001 From: Michael Usher Date: Fri, 18 Apr 2025 21:59:18 +1000 Subject: [PATCH 2/4] Merge branch 'develop' of https://github.com/mdusher/Readrops into alt-colour-scheme --- .github/ISSUE_TEMPLATE/bug_report.md | 32 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/workflows/android.yml | 11 +- CHANGELOG.md | 99 ++ README.md | 50 +- api/build.gradle.kts | 20 +- .../main/java/com/readrops/api/ApiModule.kt | 42 +- .../api/localfeed/LocalRSSDataSource.kt | 3 +- .../readrops/api/localfeed/LocalRSSHelper.kt | 4 +- .../com/readrops/api/localfeed/RSSMedia.kt | 41 + .../com/readrops/api/localfeed/XmlAdapter.kt | 2 +- .../api/localfeed/atom/ATOMFeedAdapter.kt | 5 +- .../api/localfeed/atom/ATOMItemAdapter.kt | 55 +- .../api/localfeed/json/JSONFeedAdapter.kt | 13 +- .../api/localfeed/json/JSONItemsAdapter.kt | 64 +- .../api/localfeed/rss1/RSS1FeedAdapter.kt | 15 +- .../api/localfeed/rss1/RSS1ItemAdapter.kt | 53 +- .../api/localfeed/rss2/RSS2FeedAdapter.kt | 18 +- .../api/localfeed/rss2/RSS2ItemAdapter.kt | 83 +- .../java/com/readrops/api/opml/OPMLAdapter.kt | 3 +- .../java/com/readrops/api/opml/OPMLParser.kt | 65 +- .../com/readrops/api/services/Credentials.kt | 13 +- .../api/services/fever/FeverDataSource.kt | 6 +- .../api/services/fever/FeverService.kt | 2 +- .../fever/adapters/FeverAPIAdapter.kt | 22 +- .../fever/adapters/FeverFaviconsAdapter.kt | 65 +- .../fever/adapters/FeverFeedsAdapter.kt | 99 +- .../fever/adapters/FeverFoldersAdapter.kt | 48 +- .../fever/adapters/FeverItemsAdapter.kt | 78 +- .../fever/adapters/FeverItemsIdsAdapter.kt | 15 +- .../freshrss/adapters/FreshRSSFeedsAdapter.kt | 85 -- .../adapters/FreshRSSFoldersAdapter.kt | 67 - .../freshrss/adapters/FreshRSSItemsAdapter.kt | 137 -- .../adapters/FreshRSSItemsIdsAdapter.kt | 58 - .../GReaderCredentials.kt} | 4 +- .../GReaderDataSource.kt} | 31 +- .../GReaderService.kt} | 54 +- .../GReaderSyncData.kt} | 4 +- .../greader/adapters/GReaderFeedsAdapter.kt | 99 ++ .../greader/adapters/GReaderFoldersAdapter.kt | 81 + .../greader/adapters/GReaderItemsAdapter.kt | 139 ++ .../adapters/GReaderItemsIdsAdapter.kt | 56 + .../adapters/GReaderUserInfoAdapter.kt} | 4 +- .../nextcloudnews/NextcloudNewsDataSource.kt | 12 +- .../nextcloudnews/NextcloudNewsService.kt | 2 +- .../adapters/NextcloudNewsFeedsAdapter.kt | 2 +- .../adapters/NextcloudNewsFoldersAdapter.kt | 2 +- .../adapters/NextcloudNewsItemsAdapter.kt | 32 +- .../adapters/NextcloudNewsUserAdapter.kt | 2 +- .../java/com/readrops/api/utils/ApiUtils.kt | 27 +- .../readrops/api/utils/ErrorInterceptor.kt | 3 +- .../java/com/readrops/api/utils/HtmlParser.kt | 142 +- .../api/utils/exceptions/ParseException.kt | 9 +- .../utils/extensions/JsonReaderExtensions.kt | 3 + .../utils/extensions/KonsumerExtensions.kt | 2 +- .../com/readrops/api/MockServerExtensions.kt | 2 +- .../api/localfeed/LocalRSSHelperTest.kt | 7 +- .../readrops/api/localfeed/XmlAdapterTest.kt | 7 +- .../api/localfeed/atom/ATOMAdapterTest.kt | 21 +- .../api/localfeed/json/JSONFeedAdapterTest.kt | 6 +- .../api/localfeed/rss1/RSS1AdapterTest.kt | 9 +- .../api/localfeed/rss2/RSS2AdapterTest.kt | 13 +- .../com/readrops/api/opml/OPMLParserTest.kt | 1 + .../readrops/api/services/CredentialsTest.kt | 4 +- .../api/services/fever/FeverDataSourceTest.kt | 6 +- .../fever/adapters/FeverAPIAdapterTest.kt | 8 +- .../adapters/FeverFaviconsAdapterTest.kt | 2 +- .../fever/adapters/FeverFeedsAdapterTest.kt | 2 +- .../fever/adapters/FeverFoldersAdapterTest.kt | 2 +- .../fever/adapters/FeverItemsAdapterTest.kt | 2 +- .../freshrss/FreshRSSDataSourceTest.kt | 260 ---- .../services/greader/GReaderDataSourceTest.kt | 421 ++++++ .../adapters/GReaderFeedsAdapterTest.kt} | 10 +- .../adapters/GReaderFoldersAdapterTest.kt} | 10 +- .../adapters/GReaderItemsAdapterTest.kt} | 10 +- .../adapters/GReaderItemsIdsAdapterTest.kt} | 8 +- .../adapters/GReaderUserInfoAdapterTest.kt} | 6 +- .../NextcloudNewsDataSourceTest.kt | 157 +- .../adapters/NextcloudNewsItemsAdapterTest.kt | 8 +- .../com/readrops/api/utils/ApiUtilsTest.kt | 18 + .../readrops/api/utils/AuthInterceptorTest.kt | 4 +- .../api/utils/ErrorInterceptorTest.kt | 11 +- .../com/readrops/api/utils/HtmlParserTest.kt | 114 +- .../api/utils/JsonReaderExtensionsTest.kt | 30 + .../api/utils/KonsumerExtensionsTest.kt | 9 +- .../localfeed/atom/atom_item_media_group.xml | 31 + .../resources/localfeed/atom/atom_items.xml | 2 + .../resources/localfeed/json/json_feed.json | 1 + .../resources/localfeed/rss2/rss_feed.xml | 5 + .../test/resources/services/fever/feeds.json | 5 +- .../{freshrss => greader}/adapters/feeds.json | 0 .../adapters/folders.json | 0 .../{freshrss => greader}/adapters/items.json | 0 .../adapters/items_starred_ids.json | 0 .../adapters/user_info.json | 0 .../{freshrss => greader}/login_response_body | 0 .../writetoken_response_body | 0 .../nextcloudnews/adapters/items.json | 10 +- api/src/test/resources/utils/file.html | 731 ++------- api/src/test/resources/utils/file_cp1253.html | 1315 +++++++++++++++++ app/build.gradle.kts | 63 +- app/proguard-rules.pro | 2 +- .../readrops/app/GetFoldersWithFeedsTest.kt | 86 -- .../com/readrops/app/ReadropsTestRunner.kt | 17 + .../readrops/app/SyncResultAnalyserTest.kt | 273 ---- .../java/com/readrops/app/TestApplication.kt | 47 + .../repositories/GetFoldersWithFeedsTest.kt | 231 +++ .../LocalRSSRepositoryTest.kt | 52 +- .../com/readrops/app/sync/SyncAnalyzerTest.kt | 258 ++++ .../readrops/app/testutil/ReadropsTestRule.kt | 60 + .../readrops/app/{ => testutil}/TestUtils.kt | 2 +- .../readrops/app/{ => util}/FeedColorsTest.kt | 29 +- .../main/java/com/readrops/app/AppModule.kt | 42 +- .../java/com/readrops/app/MainActivity.kt | 87 +- .../main/java/com/readrops/app/ReadropsApp.kt | 58 +- .../app/account/AccountScreenModel.kt | 36 +- .../com/readrops/app/account/AccountTab.kt | 76 +- .../credentials/AccountCredentialsScreen.kt | 84 +- .../AccountCredentialsScreenModel.kt | 94 +- .../AccountSelectionDialog.kt | 28 +- .../account/dialog/AccountWarningDialog.kt | 43 + .../account/{ => dialog}/OPMLChoiceDialog.kt | 2 +- .../{ => dialog}/OPMLImportProgressDialog.kt | 2 +- .../selection/AccountSelectionScreen.kt | 91 +- .../selection/AccountSelectionScreenModel.kt | 58 +- .../com/readrops/app/feeds/FeedScreenModel.kt | 285 ++-- .../java/com/readrops/app/feeds/FeedState.kt | 22 +- .../java/com/readrops/app/feeds/FeedTab.kt | 135 +- .../app/feeds/color/ColorPickerDialog.kt | 122 ++ .../readrops/app/feeds/color/ColorPreview.kt | 79 + .../app/feeds/color/FeedColorScreen.kt | 195 +++ .../app/feeds/color/FeedColorScreenModel.kt | 103 ++ .../app/feeds/components/FeedBanner.kt | 108 ++ .../app/feeds/{ => components}/FeedItem.kt | 6 +- .../{ => components}/FolderExpandableItem.kt | 4 +- .../app/feeds/dialogs/AddFeedDialog.kt | 137 -- .../app/feeds/dialogs/FeedBottomSheet.kt | 93 +- .../readrops/app/feeds/dialogs/FeedDialogs.kt | 148 ++ .../app/feeds/dialogs/UpdateFeedDialog.kt | 9 +- .../app/feeds/newfeed/NewFeedScreen.kt | 247 ++++ .../app/feeds/newfeed/NewFeedScreenModel.kt | 370 +++++ .../app/feeds/newfeed/ParsingResultItem.kt | 131 ++ .../java/com/readrops/app/home/HomeScreen.kt | 139 +- .../java/com/readrops/app/home/HomeTabs.kt | 21 + .../app/{base => home}/TabScreenModel.kt | 11 +- .../java/com/readrops/app/item/ItemScreen.kt | 407 +---- .../com/readrops/app/item/ItemScreenModel.kt | 320 +++- .../com/readrops/app/item/ItemScreenPage.kt | 157 ++ .../app/item/components/BackgroundTitle.kt | 93 ++ .../BottomBarNestedScrollConnection.kt | 38 + .../{ => components}/ItemScreenBottomBar.kt | 58 +- .../app/item/components/SimpleTitle.kt | 117 ++ .../app/item/view/ItemNestedScrollView.kt | 1 + .../com/readrops/app/item/view/ItemWebView.kt | 27 +- .../java/com/readrops/app/more/MoreTab.kt | 223 +-- .../readrops/app/more/debug/DebugScreen.kt | 115 ++ .../app/more/preferences/PreferencesScreen.kt | 79 +- .../preferences/PreferencesScreenModel.kt | 100 +- .../preferences/components/BasePreference.kt | 6 +- .../components/CustomShareIntentTextWidget.kt | 166 +++ .../components/RadioButtonPreferenceDialog.kt | 13 +- .../components/SwitchPreferenceWidget.kt | 20 +- .../app/notifications/NotificationsScreen.kt | 4 +- .../app/repositories/FeverRepository.kt | 2 +- ...hRSSRepository.kt => GReaderRepository.kt} | 24 +- .../app/repositories/GetFoldersWithFeeds.kt | 19 +- .../app/repositories/LocalRSSRepository.kt | 113 +- .../repositories/NextcloudNewsRepository.kt | 10 +- .../readrops/app/repositories/Repository.kt | 275 ++-- .../com/readrops/app/sync/SyncAnalyzer.kt | 83 +- .../java/com/readrops/app/sync/SyncWorker.kt | 258 +--- .../com/readrops/app/sync/Synchronizer.kt | 245 +++ .../app/timelime/FilterBottomSheet.kt | 85 -- .../com/readrops/app/timelime/TimelineItem.kt | 113 -- .../app/timelime/TimelineScreenModel.kt | 273 ++-- .../com/readrops/app/timelime/TimelineTab.kt | 397 +++-- .../app/timelime/components/TimelineAppBar.kt | 93 ++ .../app/timelime/components/TimelineItem.kt | 257 ++++ .../{ => components}/TimelineItemParts.kt | 103 +- .../timelime/{ => dialog}/ErrorListDialog.kt | 7 +- .../app/timelime/dialog/FilterBottomSheet.kt | 187 +++ .../timelime/dialog/OpenInParameterDialog.kt | 89 ++ .../app/timelime/dialog/TimelineDialogs.kt | 95 ++ .../app/timelime/drawer/DrawerFolderItem.kt | 62 +- .../app/timelime/drawer/TimelineDrawer.kt | 58 +- .../com/readrops/app/util/CrashActivity.kt | 6 +- .../com/readrops/app/util/ErrorMessage.kt | 42 - .../java/com/readrops/app/util/FeedColors.kt | 32 +- .../readrops/app/util/FeverFaviconFetcher.kt | 28 +- .../java/com/readrops/app/util/Migrations.kt | 48 + .../com/readrops/app/util/PagingConstants.kt | 7 + .../java/com/readrops/app/util/Preferences.kt | 69 +- .../app/util/ShareIntentTextRenderer.kt | 131 ++ .../main/java/com/readrops/app/util/Utils.kt | 36 + .../app/util/accounterror/AccountError.kt | 75 + .../app/util/accounterror/GReaderError.kt | 57 + .../util/accounterror/NextcloudNewsError.kt | 64 + .../app/util/components/DropdownBox.kt | 139 ++ .../readrops/app/util/components/FeedIcon.kt | 2 +- .../readrops/app/util/components/IconText.kt | 10 +- .../app/util/components/LoadingButton.kt | 57 + .../app/util/components/LoadingTextButton.kt | 29 - .../app/util/components/RefreshScreen.kt | 20 + .../app/util/components/SwitchText.kt | 70 + .../app/util/components/TextFieldUtils.kt | 4 +- .../util/components/TextHorizontalDivider.kt | 27 + .../app/util/components/dialog/ErrorDialog.kt | 6 +- .../util/components/dialog/TextFieldDialog.kt | 8 +- .../app/util/extensions/ContextExtensions.kt | 61 + .../app/util/{ => extensions}/Extensions.kt | 13 +- .../app/util/extensions/FeedExtensions.kt | 18 + .../extensions/LazyPagingItemsExtensions.kt | 16 + .../app/util/theme/BlackWhiteColor.kt | 75 + .../java/com/readrops/app/util/theme/Color.kt | 198 +-- .../java/com/readrops/app/util/theme/Theme.kt | 277 ++-- app/src/main/res/drawable/ic_account.xml | 5 + app/src/main/res/drawable/ic_more_vert.xml | 9 + app/src/main/res/drawable/ic_reset_color.xml | 5 + app/src/main/res/drawable/ic_undo.xml | 11 + app/src/main/res/drawable/ic_warning.xml | 9 + app/src/main/res/values-de/strings.xml | 159 +- app/src/main/res/values-es/strings.xml | 100 +- app/src/main/res/values-fr/strings.xml | 122 +- app/src/main/res/values-in/strings.xml | 53 +- app/src/main/res/values-it/strings.xml | 52 +- app/src/main/res/values-ja/strings.xml | 163 ++ app/src/main/res/values-nb-rNO/strings.xml | 56 +- app/src/main/res/values-nl/strings.xml | 163 +- app/src/main/res/values-pt-rBR/strings.xml | 203 ++- app/src/main/res/values-ta/strings.xml | 206 +++ app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 227 +++ app/src/main/res/values-zh-rTW/strings.xml | 180 +++ app/src/main/res/values/html.xml | 18 +- app/src/main/res/values/strings.xml | 165 ++- .../java/com/readrops/app/TemplateTest.kt | 36 + build.gradle.kts | 102 +- codecov.yml | 4 + db/build.gradle.kts | 23 +- db/schemas/com.readrops.db.Database/5.json | 545 +++++++ .../java/com/readrops/db/MigrationsTest.kt | 12 + .../java/com/readrops/db/dao/FeedDaoTest.kt | 2 +- .../java/com/readrops/db/dao/FolderDaoTest.kt | 2 +- .../queries/ItemSelectionQueryBuilderTest.kt | 50 + .../db/{ => queries}/ItemsQueryBuilderTest.kt | 68 +- db/src/main/java/com/readrops/db/Database.kt | 36 +- db/src/main/java/com/readrops/db/DbModule.kt | 7 +- .../java/com/readrops/db/dao/AccountDao.kt | 2 +- .../main/java/com/readrops/db/dao/BaseDao.kt | 7 + .../main/java/com/readrops/db/dao/FeedDao.kt | 51 +- .../main/java/com/readrops/db/dao/ItemDao.kt | 44 +- .../com/readrops/db/dao/ItemStateChangeDao.kt | 45 +- .../java/com/readrops/db/dao/ItemStateDao.kt | 18 + .../java/com/readrops/db/entities/Feed.kt | 11 +- .../readrops/db/entities/account/Account.kt | 37 +- .../db/entities/account/AccountConfig.kt | 11 +- .../db/entities/account/AccountType.kt | 18 +- .../java/com/readrops/db/filters/Filters.kt | 25 +- .../com/readrops/db/filters/ListSortType.kt | 6 - .../com/readrops/db/pojo/FeedWithFolder.kt | 5 + .../java/com/readrops/db/pojo/ItemWithFeed.kt | 37 +- .../db/queries/FoldersAndFeedsQueryBuilder.kt | 48 +- .../db/queries/ItemSelectionQueryBuilder.kt | 21 +- .../readrops/db/queries/ItemsQueryBuilder.kt | 77 +- .../java/com/readrops/db/util/Converters.kt | 11 - .../java/com/readrops/db/util/DateUtils.kt | 44 +- db/src/main/res/drawable/ic_google_reader.xml | 24 + db/src/main/res/values/strings.xml | 4 +- .../metadata/android/en-US/changelogs/16.txt | 4 + .../metadata/android/en-US/changelogs/17.txt | 6 + .../metadata/android/en-US/changelogs/18.txt | 4 + .../metadata/android/en-US/changelogs/19.txt | 2 + .../metadata/android/en-US/changelogs/20.txt | 3 + .../metadata/android/en-US/changelogs/21.txt | 5 + .../android/en-US/full_description.txt | 26 +- .../images/phoneScreenshots/Screenshot_1.jpg | Bin 0 -> 348367 bytes .../images/phoneScreenshots/Screenshot_1.png | Bin 130120 -> 0 bytes .../images/phoneScreenshots/Screenshot_2.jpg | Bin 0 -> 529026 bytes .../images/phoneScreenshots/Screenshot_2.png | Bin 117920 -> 0 bytes .../images/phoneScreenshots/Screenshot_3.jpg | Bin 0 -> 316503 bytes .../images/phoneScreenshots/Screenshot_3.png | Bin 2218649 -> 0 bytes .../images/phoneScreenshots/Screenshot_4.jpg | Bin 0 -> 251546 bytes .../images/phoneScreenshots/Screenshot_4.png | Bin 984595 -> 0 bytes .../images/phoneScreenshots/Screenshot_5.jpg | Bin 0 -> 346902 bytes .../images/phoneScreenshots/Screenshot_5.png | Bin 318928 -> 0 bytes .../images/phoneScreenshots/Screenshot_6.jpg | Bin 0 -> 380434 bytes .../images/phoneScreenshots/Screenshot_6.png | Bin 106612 -> 0 bytes .../images/phoneScreenshots/Screenshot_7.png | Bin 344117 -> 0 bytes .../images/phoneScreenshots/Screenshot_8.png | Bin 235258 -> 0 bytes gradle.properties | 27 +- gradle/libs.versions.toml | 67 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 292 files changed, 13643 insertions(+), 5818 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 api/src/main/java/com/readrops/api/localfeed/RSSMedia.kt delete mode 100644 api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapter.kt delete mode 100644 api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapter.kt delete mode 100644 api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt delete mode 100644 api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapter.kt rename api/src/main/java/com/readrops/api/services/{freshrss/FreshRSSCredentials.kt => greader/GReaderCredentials.kt} (66%) rename api/src/main/java/com/readrops/api/services/{freshrss/FreshRSSDataSource.kt => greader/GReaderDataSource.kt} (88%) rename api/src/main/java/com/readrops/api/services/{freshrss/FreshRSSService.kt => greader/GReaderService.kt} (56%) rename api/src/main/java/com/readrops/api/services/{freshrss/FreshRSSSyncData.kt => greader/GReaderSyncData.kt} (74%) create mode 100644 api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapter.kt create mode 100644 api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapter.kt create mode 100644 api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapter.kt create mode 100644 api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapter.kt rename api/src/main/java/com/readrops/api/services/{freshrss/adapters/FreshRSSUserInfoAdapter.kt => greader/adapters/GReaderUserInfoAdapter.kt} (87%) delete mode 100644 api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt create mode 100644 api/src/test/java/com/readrops/api/services/greader/GReaderDataSourceTest.kt rename api/src/test/java/com/readrops/api/services/{freshrss/adapters/FreshRSSFeedsAdapterTest.kt => greader/adapters/GReaderFeedsAdapterTest.kt} (79%) rename api/src/test/java/com/readrops/api/services/{freshrss/adapters/FreshRSSFoldersAdapterTest.kt => greader/adapters/GReaderFoldersAdapterTest.kt} (72%) rename api/src/test/java/com/readrops/api/services/{freshrss/adapters/FreshRSSItemsAdapterTest.kt => greader/adapters/GReaderItemsAdapterTest.kt} (84%) rename api/src/test/java/com/readrops/api/services/{freshrss/adapters/FreshRSSItemsIdsAdapterTest.kt => greader/adapters/GReaderItemsIdsAdapterTest.kt} (83%) rename api/src/test/java/com/readrops/api/services/{freshrss/adapters/FreshRSSUserInfoAdapterTest.kt => greader/adapters/GReaderUserInfoAdapterTest.kt} (72%) create mode 100644 api/src/test/resources/localfeed/atom/atom_item_media_group.xml rename api/src/test/resources/services/{freshrss => greader}/adapters/feeds.json (100%) rename api/src/test/resources/services/{freshrss => greader}/adapters/folders.json (100%) rename api/src/test/resources/services/{freshrss => greader}/adapters/items.json (100%) rename api/src/test/resources/services/{freshrss => greader}/adapters/items_starred_ids.json (100%) rename api/src/test/resources/services/{freshrss => greader}/adapters/user_info.json (100%) rename api/src/test/resources/services/{freshrss => greader}/login_response_body (100%) rename api/src/test/resources/services/{freshrss => greader}/writetoken_response_body (100%) create mode 100644 api/src/test/resources/utils/file_cp1253.html delete mode 100644 app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt create mode 100644 app/src/androidTest/java/com/readrops/app/ReadropsTestRunner.kt delete mode 100644 app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt create mode 100644 app/src/androidTest/java/com/readrops/app/TestApplication.kt create mode 100644 app/src/androidTest/java/com/readrops/app/repositories/GetFoldersWithFeedsTest.kt rename app/src/androidTest/java/com/readrops/app/{ => repositories}/LocalRSSRepositoryTest.kt (67%) create mode 100644 app/src/androidTest/java/com/readrops/app/sync/SyncAnalyzerTest.kt create mode 100644 app/src/androidTest/java/com/readrops/app/testutil/ReadropsTestRule.kt rename app/src/androidTest/java/com/readrops/app/{ => testutil}/TestUtils.kt (82%) rename app/src/androidTest/java/com/readrops/app/{ => util}/FeedColorsTest.kt (62%) rename app/src/main/java/com/readrops/app/account/{selection => dialog}/AccountSelectionDialog.kt (52%) create mode 100644 app/src/main/java/com/readrops/app/account/dialog/AccountWarningDialog.kt rename app/src/main/java/com/readrops/app/account/{ => dialog}/OPMLChoiceDialog.kt (97%) rename app/src/main/java/com/readrops/app/account/{ => dialog}/OPMLImportProgressDialog.kt (94%) create mode 100644 app/src/main/java/com/readrops/app/feeds/color/ColorPickerDialog.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/color/ColorPreview.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/color/FeedColorScreen.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/color/FeedColorScreenModel.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/components/FeedBanner.kt rename app/src/main/java/com/readrops/app/feeds/{ => components}/FeedItem.kt (94%) rename app/src/main/java/com/readrops/app/feeds/{ => components}/FolderExpandableItem.kt (96%) delete mode 100644 app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/dialogs/FeedDialogs.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreen.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreenModel.kt create mode 100644 app/src/main/java/com/readrops/app/feeds/newfeed/ParsingResultItem.kt create mode 100644 app/src/main/java/com/readrops/app/home/HomeTabs.kt rename app/src/main/java/com/readrops/app/{base => home}/TabScreenModel.kt (88%) create mode 100644 app/src/main/java/com/readrops/app/item/ItemScreenPage.kt create mode 100644 app/src/main/java/com/readrops/app/item/components/BackgroundTitle.kt create mode 100644 app/src/main/java/com/readrops/app/item/components/BottomBarNestedScrollConnection.kt rename app/src/main/java/com/readrops/app/item/{ => components}/ItemScreenBottomBar.kt (64%) create mode 100644 app/src/main/java/com/readrops/app/item/components/SimpleTitle.kt create mode 100644 app/src/main/java/com/readrops/app/more/debug/DebugScreen.kt create mode 100644 app/src/main/java/com/readrops/app/more/preferences/components/CustomShareIntentTextWidget.kt rename app/src/main/java/com/readrops/app/repositories/{FreshRSSRepository.kt => GReaderRepository.kt} (91%) create mode 100644 app/src/main/java/com/readrops/app/sync/Synchronizer.kt delete mode 100644 app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt delete mode 100644 app/src/main/java/com/readrops/app/timelime/TimelineItem.kt create mode 100644 app/src/main/java/com/readrops/app/timelime/components/TimelineAppBar.kt create mode 100644 app/src/main/java/com/readrops/app/timelime/components/TimelineItem.kt rename app/src/main/java/com/readrops/app/timelime/{ => components}/TimelineItemParts.kt (77%) rename app/src/main/java/com/readrops/app/timelime/{ => dialog}/ErrorListDialog.kt (84%) create mode 100644 app/src/main/java/com/readrops/app/timelime/dialog/FilterBottomSheet.kt create mode 100644 app/src/main/java/com/readrops/app/timelime/dialog/OpenInParameterDialog.kt create mode 100644 app/src/main/java/com/readrops/app/timelime/dialog/TimelineDialogs.kt delete mode 100644 app/src/main/java/com/readrops/app/util/ErrorMessage.kt create mode 100644 app/src/main/java/com/readrops/app/util/Migrations.kt create mode 100644 app/src/main/java/com/readrops/app/util/PagingConstants.kt create mode 100644 app/src/main/java/com/readrops/app/util/ShareIntentTextRenderer.kt create mode 100644 app/src/main/java/com/readrops/app/util/accounterror/AccountError.kt create mode 100644 app/src/main/java/com/readrops/app/util/accounterror/GReaderError.kt create mode 100644 app/src/main/java/com/readrops/app/util/accounterror/NextcloudNewsError.kt create mode 100644 app/src/main/java/com/readrops/app/util/components/DropdownBox.kt create mode 100644 app/src/main/java/com/readrops/app/util/components/LoadingButton.kt delete mode 100644 app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt create mode 100644 app/src/main/java/com/readrops/app/util/components/SwitchText.kt create mode 100644 app/src/main/java/com/readrops/app/util/components/TextHorizontalDivider.kt create mode 100644 app/src/main/java/com/readrops/app/util/extensions/ContextExtensions.kt rename app/src/main/java/com/readrops/app/util/{ => extensions}/Extensions.kt (66%) create mode 100644 app/src/main/java/com/readrops/app/util/extensions/FeedExtensions.kt create mode 100644 app/src/main/java/com/readrops/app/util/extensions/LazyPagingItemsExtensions.kt create mode 100644 app/src/main/java/com/readrops/app/util/theme/BlackWhiteColor.kt create mode 100644 app/src/main/res/drawable/ic_account.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_reset_color.xml create mode 100644 app/src/main/res/drawable/ic_undo.xml create mode 100644 app/src/main/res/drawable/ic_warning.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-ta/strings.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values-zh-rTW/strings.xml create mode 100644 app/src/test/java/com/readrops/app/TemplateTest.kt create mode 100644 codecov.yml create mode 100644 db/schemas/com.readrops.db.Database/5.json create mode 100644 db/src/androidTest/java/com/readrops/db/queries/ItemSelectionQueryBuilderTest.kt rename db/src/androidTest/java/com/readrops/db/{ => queries}/ItemsQueryBuilderTest.kt (64%) delete mode 100644 db/src/main/java/com/readrops/db/filters/ListSortType.kt create mode 100644 db/src/main/res/drawable/ic_google_reader.xml create mode 100644 fastlane/metadata/android/en-US/changelogs/16.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/17.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/18.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/19.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/20.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/21.txt create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.jpg delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.png delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_7.png delete mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_8.png diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..b1ab38a2f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us fix a bug +title: "[Bug] " +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment information (please complete the following information):** + - Account type: [e.g. FreshRSS, Nextcloud News] + - App version: [e.g. 2.0] + - Android version: [e.g. Android 13, 14] + - Device type: [e.g. One Plus 12, Samsung Galaxy S23] + - Store: [e.g F-Droid, Play Store, standalone apk] + - [ ] Stacktrace collected from crash screen + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..f9acdb30b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature]" +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f824a80f5..f6df8512b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -26,12 +26,13 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Android Emulator Runner - uses: ReactiveCircus/android-emulator-runner@v2.30.1 + uses: ReactiveCircus/android-emulator-runner@v2.33.0 with: api-level: 29 - script: ./gradlew clean build connectedCheck - - uses: codecov/codecov-action@v2.1.0 + script: ./gradlew clean build connectedCheck jacocoFullReport + - uses: codecov/codecov-action@v4 with: - files: ./build/reports/jacoco/jacocoFullReport.xml - fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + files: ./build/reports/jacoco/jacocoFullReport/jacocoFullReport.xml + fail_ci_if_error: true verbose: true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d94910a3..95979ec71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,101 @@ +# v2.1.0 + +This release focuses mainly on QOL improvements. You will find among them long time requested features. I hope in the future to be able to deliver new releases in less than six months. + +## New features + +- 🆕 Pager in article screen: You can now swipe left/ right to go to the previous/next article without going back to the main list #62 +- 🆕 New feed screen. A new screen replaces the dialog to add a feed. + - All declared RSS resources in a website will appear in a list. You will be able to select only the ones you would like to add + - You can now directly choose a folder for each feed to add +- 🆕 Feed color screen: You can now change each feed color in a new screen. #104 +- 🆕 Per feed open parameter: you can now choose for each feed to open its articles in the article screen or directly in the external view. #105, #125 +- 🆕 Initial tablet mode: left navigation bar and permanent navigation drawer in Timeline tab. More improvements will come for a 100% big screen support +- 🆕 Parameter to launch synchronization at startup #158 +- 🆕 Parameter to set the default drawer filter at launch +- 🆕 Two parameters to customize left and right swipe actions in Timeline tab #117 +- 🆕 Google Reader API which powers FreshRSS support can now be used as a standalone API +- 🆕 Modify shared article text with a custom template +- 🆕 Android 15 support + +## Improvements + +- Local account: + - Synchronization speed improvements + - Icons quality improved (currently only new icons will have better quality, a global option to reload all icons will come in the future) + - Display feed banner in feed bottom sheet (currently new feeds only) + - Improve media support for RSS2 and ATOM (Youtube...) + - Date parsing improvements +- OPML import speed improvements +- Display feed notifications parameter in feed bottom sheet +- Make login checks less restrictive #193 +- Move Folders beginning with \_ on top #78 +- Improve feed color handling #172 +- Autofill managers are now usable in login screen #253 +- Translation updates + +## Fixes + +- Downloaded image now appears in media gallery #226 +- Various image share/download fixes in article screen +- Fix scroll jump in article screen when touching the screen for the first time #184 +- Fix a rare case where local parsing could fail #246 +- Fix Nextcloud News feed creation where all local feeds could be deleted +- Fix crash when opening empty OPML file #244, #245 +- Fix hide feeds without new items parameter for some accounts #255 +- Fix crash when no item link was provided #247 + +## Contributions + +- Thanks to all translators who worked on Weblate! #283, #274, #256, #241 +- FreshRSS casing #230 by @Alkarex +- Fix endpoint slash #231 by @Alkarex +- Fix formatting of plain text items #236 by @equeim +- Two minor improvements for the German localization #237 by @BorisBrock +- Integrate login screen with autofill managers (#253) by @christophehenry +- UX: increase DrawerFolderItem's expand button to avoid missclicks (#257) by @christophehenry +- Corrects a gramatical form (#248) by @StuntsPT +- Customize shared text using template setting (#254) by @christophehenry +- Fix gap above bottom navigation bar (#266) by @equeim +- Fix colors on More tab (#270) by @equeim +- Fix HTML parsing (#273) by @equeim +- Share Intent template: Add french typography filter + improve template dialog (#269) by @christophehenry +- Add mecanism to deduce feeds location in special cases (#272) by @christophehenry +- Add managed punctuation marks to fr_typo (#279) by @christophehenry +- Allow sourcing account credentials from local.properties during development (#275) by @christophehenry +- Fix unread feed selection in FoldersAndFeedsQueryBuilder (#276) by @christophehenry + +# v2.0.3 +- Fix Fever API compatibility with TinyTiny RSS and yarr, should also fix other providers (#228 + #229) +- Fix Nextcloud News item duplicates when syncing which would made the app unusable +- Fix Nextcloud News item parsing: items with no title will be ignored + +# v2.0.2 +- Fix crash when opening app from a notification (#223) +- Fix Fever API synchronization error (#228) + +# v2.0.1 + +- Make Timeline tab filters persistent (#138) +- Change Timeline tab order field default value (#202) +- Fix crash when adding a Fever API account (#200) +- Be less strict with feed and folder names (#206) + +# v2.0 + +- Restore swipe to mark as read (#188) +- Restore Ordering by article id in Timeline tab +- Improve OPML file picker filtering (#195) +- Translation updates +- See previous beta versions to get full changelog since v1.3 + +# v2.0-beta02 + +- Fix migration issues from v1.3 and older (especially for F-Droid builds) +- Make Preferences screen scrollable (#190) +- Fix wrong translation in RadioButtonPreferenceDialog (#185) +- Translation updates + # v2.0-beta01 ## General @@ -40,6 +138,7 @@ - Migrate to Nextcloud News API 1.3 - 🆕 Follow system theme option (default) - 🆕 Option to disable battery optimization for background synchronization +- Add support for new Android versions until Android 14 (API 34) ## Technical diff --git a/README.md b/README.md index d3c83967f..48d982bc9 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,60 @@ -

Readrops 2.0 first beta is out! -

-

- +

Readrops

- + -

Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are information drops in an ocean of news.

+

Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are articles in an ocean of news.

- - -

- + +

# Features -- Local RSS parsing : support for RSS 2, RSS 1, ATOM and JSONFeed -- Nextcloud news support -- FreshRSS support -- Multiple accounts -- Feeds and folders management (create, update and delete feeds/folders if your service API supports it) +- Local RSS parsing (RSS1, RSS2, ATOM, JSONFeed) +- External services: + - FreshRSS + - Nextcloud News + - Fever API + - Google Reader API +- Multi-account +- Feeds and folders management (create, update and delete feeds/folders if supported by the service API) +- OPML import/export - Background synchronisation - Notifications # Screenshots - - - + - + # Licence This project is released under the GPLv3 licence. +# Develop + +During development, you can autofill the app's login form by filling the project's `local.properties` like so: + +```properties +debug..login= +debug..password= +debug..url=https\:// + +# For instance: +debug.nextcloud_news.login=Test user +debug.nextcloud_news.password=1234 +debug.nextcloud_news.url=https\://rss.example.com +``` + # Donations [](https://paypal.me/readropsapp) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 40e547514..67ba634ff 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -7,6 +7,10 @@ android { namespace = "com.readrops.api" buildTypes { + debug { + enableUnitTestCoverage = true + } + create("beta") { initWith(getByName("release")) @@ -26,6 +30,9 @@ android { lint { abortOnError = false + + // disable lint rule which isn't supposed to be applied on a non compose module + disable.add("CoroutineCreationDuringComposition") } } @@ -34,23 +41,15 @@ dependencies { coreLibraryDesugaring(libs.jdk.desugar) - testImplementation(libs.junit4) - implementation(libs.coroutines.core) - testImplementation(libs.coroutines.test) implementation(platform(libs.koin.bom)) implementation(libs.bundles.koin) - //testImplementation(libs.bundles.kointest) - // I don't know why but those dependencies are unreachable when accessed directly from version catalog - testImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}") - testImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}") implementation(libs.konsumexml) implementation(libs.kotlinxmlbuilder) implementation(libs.okhttp) - testImplementation(libs.okhttp.mockserver) implementation(libs.bundles.retrofit) { exclude("com.squareup.okhttp3", "okhttp3") @@ -59,4 +58,9 @@ dependencies { implementation(libs.moshi) implementation(libs.jsoup) + + testImplementation(libs.junit4) + testImplementation(libs.coroutines.test) + testImplementation(libs.bundles.kointest) + testImplementation(libs.okhttp.mockserver) } diff --git a/api/src/main/java/com/readrops/api/ApiModule.kt b/api/src/main/java/com/readrops/api/ApiModule.kt index 726d1494f..e74159cb8 100644 --- a/api/src/main/java/com/readrops/api/ApiModule.kt +++ b/api/src/main/java/com/readrops/api/ApiModule.kt @@ -4,14 +4,20 @@ import com.readrops.api.localfeed.LocalRSSDataSource import com.readrops.api.services.Credentials import com.readrops.api.services.fever.FeverDataSource import com.readrops.api.services.fever.FeverService -import com.readrops.api.services.fever.adapters.* -import com.readrops.api.services.freshrss.FreshRSSDataSource -import com.readrops.api.services.freshrss.FreshRSSService -import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter -import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter -import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter -import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter -import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfoAdapter +import com.readrops.api.services.fever.adapters.FeverAPIAdapter +import com.readrops.api.services.fever.adapters.FeverFaviconsAdapter +import com.readrops.api.services.fever.adapters.FeverFeeds +import com.readrops.api.services.fever.adapters.FeverFeedsAdapter +import com.readrops.api.services.fever.adapters.FeverFoldersAdapter +import com.readrops.api.services.fever.adapters.FeverItemsAdapter +import com.readrops.api.services.fever.adapters.FeverItemsIdsAdapter +import com.readrops.api.services.greader.GReaderDataSource +import com.readrops.api.services.greader.GReaderService +import com.readrops.api.services.greader.adapters.FreshRSSUserInfoAdapter +import com.readrops.api.services.greader.adapters.GReaderFeedsAdapter +import com.readrops.api.services.greader.adapters.GReaderFoldersAdapter +import com.readrops.api.services.greader.adapters.GReaderItemsAdapter +import com.readrops.api.services.greader.adapters.GReaderItemsIdsAdapter import com.readrops.api.services.nextcloudnews.NextcloudNewsDataSource import com.readrops.api.services.nextcloudnews.NextcloudNewsService import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFeedsAdapter @@ -47,30 +53,30 @@ val apiModule = module { single { LocalRSSDataSource(get()) } - //region freshrss + //region greader/freshrss - factory { params -> FreshRSSDataSource(get(parameters = { params })) } + factory { params -> GReaderDataSource(get(parameters = { params })) } factory { (credentials: Credentials) -> Retrofit.Builder() .baseUrl(credentials.url) .client(get()) - .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) + .addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi")))) .build() - .create(FreshRSSService::class.java) + .create(GReaderService::class.java) } - single(named("freshrssMoshi")) { + single(named("greaderMoshi")) { Moshi.Builder() - .add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter()) - .add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter()) - .add(FreshRSSFeedsAdapter()) - .add(FreshRSSFoldersAdapter()) + .add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter()) + .add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter()) + .add(GReaderFeedsAdapter()) + .add(GReaderFoldersAdapter()) .add(FreshRSSUserInfoAdapter()) .build() } - //endregion freshrss + //endregion greader/freshrss //region nextcloud news diff --git a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt index cf2a4bc6e..a9cb3f992 100644 --- a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt +++ b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt @@ -111,8 +111,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { // if we can't guess type based on content-type header, we use the content if (type == LocalRSSHelper.RSSType.UNKNOWN) { try { - konsumer = response.body!!.byteStream().konsumeXml() - rootKonsumer = konsumer.nextElement(LocalRSSHelper.RSS_ROOT_NAMES) + rootKonsumer = konsumer?.nextElement(LocalRSSHelper.RSS_ROOT_NAMES) if (rootKonsumer != null) { type = LocalRSSHelper.guessRSSType(rootKonsumer) diff --git a/api/src/main/java/com/readrops/api/localfeed/LocalRSSHelper.kt b/api/src/main/java/com/readrops/api/localfeed/LocalRSSHelper.kt index 40bb5f6ac..86aa35716 100644 --- a/api/src/main/java/com/readrops/api/localfeed/LocalRSSHelper.kt +++ b/api/src/main/java/com/readrops/api/localfeed/LocalRSSHelper.kt @@ -3,7 +3,6 @@ package com.readrops.api.localfeed import com.gitlab.mvysny.konsumexml.Konsumer import com.gitlab.mvysny.konsumexml.Names import com.readrops.api.utils.extensions.checkRoot -import java.io.InputStream object LocalRSSHelper { @@ -26,12 +25,11 @@ object LocalRSSHelper { RSS_1_CONTENT_TYPE -> RSSType.RSS_1 RSS_2_CONTENT_TYPE -> RSSType.RSS_2 ATOM_CONTENT_TYPE -> RSSType.ATOM - JSON_CONTENT_TYPE, JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED + JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED else -> RSSType.UNKNOWN } } - @JvmStatic fun isRSSType(type: String?): Boolean = if (type != null) getRSSType(type) != RSSType.UNKNOWN else false diff --git a/api/src/main/java/com/readrops/api/localfeed/RSSMedia.kt b/api/src/main/java/com/readrops/api/localfeed/RSSMedia.kt new file mode 100644 index 000000000..6995e4f57 --- /dev/null +++ b/api/src/main/java/com/readrops/api/localfeed/RSSMedia.kt @@ -0,0 +1,41 @@ +package com.readrops.api.localfeed + +import com.gitlab.mvysny.konsumexml.Konsumer +import com.gitlab.mvysny.konsumexml.Names +import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore +import com.readrops.api.utils.extensions.nullableTextRecursively +import com.readrops.db.entities.Item + +object RSSMedia { + + fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) { + val url = attributes.getValueOrNull("url") + + if (url != null && isUrlImage(url) && item.imageLink == null) { + item.imageLink = url + } + + konsumer.skipContents() // ignore media content sub elements + } + + fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(konsumer) { + allChildrenAutoIgnore(Names.of("content", "thumbnail", "description")) { + when (tagName) { + "media:content" -> parseMediaContent(this, item) + "media:thumbnail"-> parseMediaContent(this, item) + "media:description" -> { + // Youtube case, might be useful for others + val description = nullableTextRecursively() + if (item.text == null) { + item.content = description + } + } + else -> skipContents() + } + } + } + + private fun isUrlImage(url: String): Boolean = with(url) { + return endsWith(".jpg") || endsWith(".jpeg") || endsWith(".png") + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt index 09642bea6..502b8b918 100644 --- a/api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt @@ -7,7 +7,7 @@ import com.readrops.api.localfeed.rss2.RSS2FeedAdapter import com.readrops.db.entities.Feed import com.readrops.db.entities.Item -interface XmlAdapter { +fun interface XmlAdapter { fun fromXml(konsumer: Konsumer): T diff --git a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt index 3f2d63ae8..8b550b372 100644 --- a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt @@ -28,6 +28,7 @@ class ATOMFeedAdapter : XmlAdapter>> { "title" -> name = nonNullText() "link" -> parseLink(this@allChildrenAutoIgnore, feed) "subtitle" -> description = nullableText() + "logo" -> imageUrl = nullableText() "entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) else -> skipContents() } @@ -38,7 +39,7 @@ class ATOMFeedAdapter : XmlAdapter>> { konsumer.close() Pair(feed, items) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("ATOM feed parsing failure", e) } } @@ -52,6 +53,6 @@ class ATOMFeedAdapter : XmlAdapter>> { } companion object { - val names = Names.of("title", "link", "subtitle", "entry") + val names = Names.of("title", "link", "subtitle", "logo", "entry") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt index de0cc8df3..92df1ba06 100644 --- a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMItemAdapter.kt @@ -3,6 +3,7 @@ package com.readrops.api.localfeed.atom import com.gitlab.mvysny.konsumexml.Konsumer import com.gitlab.mvysny.konsumexml.Names import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore +import com.readrops.api.localfeed.RSSMedia import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nonNullText @@ -17,46 +18,50 @@ class ATOMItemAdapter : XmlAdapter { override fun fromXml(konsumer: Konsumer): Item { val item = Item() - return try { - item.apply { - konsumer.allChildrenAutoIgnore(names) { - when (tagName) { - "title" -> title = nonNullText() - "id" -> remoteId = nullableText() - "updated" -> pubDate = DateUtils.parse(nullableText()) - "link" -> parseLink(this, this@apply) - "author" -> allChildrenAutoIgnore("name") { author = nullableText() } - "summary" -> description = nullableTextRecursively() - "content" -> content = nullableTextRecursively() - else -> skipContents() + return item.apply { + konsumer.allChildrenAutoIgnore(names) { + when (tagName) { + "title" -> title = nonNullText() + "id" -> remoteId = nullableText() + "published" -> pubDate = DateUtils.parse(nullableText()) + "updated" -> { + val updated = nullableText() + if (pubDate == null) { + pubDate = DateUtils.parse(updated) + } } + + "link" -> parseLink(this, this@apply) + "author" -> allChildrenAutoIgnore("name") { author = nullableText() } + "summary" -> description = nullableTextRecursively() + "content" -> content = nullableTextRecursively() + "media:group" -> RSSMedia.parseMediaGroup(this, item) + else -> skipContents() } } - validateItem(item) - if (item.pubDate == null) item.pubDate = LocalDateTime.now() - if (item.remoteId == null) item.remoteId = item.link - - item - } catch (e: Exception) { - throw ParseException(e.message) + validateItem(this) + if (pubDate == null) pubDate = LocalDateTime.now() + if (remoteId == null) remoteId = link } } private fun parseLink(konsumer: Konsumer, item: Item) = with(konsumer) { - if (attributes.getValueOrNull("rel") == null || - attributes["rel"] == "alternate") + if (attributes.getValueOrNull("rel") == null || attributes["rel"] == "alternate") item.link = attributes.getValueOrNull("href") } - private fun validateItem(item: Item) { + private fun validateItem(item: Item) = with(item) { when { - item.title == null -> throw ParseException("Item title is required") - item.link == null -> throw ParseException("Item link is required") + title == null -> throw ParseException("Item title is required") + link == null -> throw ParseException("Item link is required") } } companion object { - val names = Names.of("title", "id", "updated", "link", "author", "summary", "content") + val names = Names.of( + "title", "id", "updated", "link", "author", "summary", + "content", "group", "published" + ) } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt index e8f505725..039444fd4 100644 --- a/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt @@ -5,7 +5,9 @@ import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString import com.readrops.db.entities.Feed import com.readrops.db.entities.Item -import com.squareup.moshi.* +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter class JSONFeedAdapter : JsonAdapter>>() { @@ -27,8 +29,9 @@ class JSONFeedAdapter : JsonAdapter>>() { 0 -> name = reader.nextNonEmptyString() 1 -> siteUrl = reader.nextNullableString() 2 -> url = reader.nextNullableString() - 3 -> description = reader.nextNullableString() - 4 -> items += itemAdapter.fromJson(reader) + 3 -> imageUrl = reader.nextNullableString() + 4 -> description = reader.nextNullableString() + 5 -> items += itemAdapter.fromJson(reader) else -> reader.skipValue() } } @@ -37,11 +40,11 @@ class JSONFeedAdapter : JsonAdapter>>() { reader.endObject() Pair(feed, items) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("JSON feed parsing failure", e) } companion object { val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url", - "feed_url", "description", "items") + "feed_url", "icon", "description", "items") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt index 1106193a1..ae9ff8196 100644 --- a/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/json/JSONItemsAdapter.kt @@ -20,47 +20,43 @@ class JSONItemsAdapter : JsonAdapter>() { override fun fromJson(reader: JsonReader): List = with(reader) { val items = arrayListOf() - try { - beginArray() + beginArray() + + while (hasNext()) { + beginObject() + val item = Item() + + var contentText: String? = null + var contentHtml: String? = null while (hasNext()) { - beginObject() - val item = Item() - - var contentText: String? = null - var contentHtml: String? = null - - while (hasNext()) { - with(item) { - when (selectName(names)) { - 0 -> remoteId = nextNonEmptyString() - 1 -> link = nextNonEmptyString() - 2 -> title = nextNonEmptyString() - 3 -> contentHtml = nextNullableString() - 4 -> contentText = nextNullableString() - 5 -> description = nextNullableString() - 6 -> imageLink = nextNullableString() - 7 -> pubDate = DateUtils.parse(nextNullableString()) - 8 -> author = parseAuthor(reader) // jsonfeed 1.0 - 9 -> author = parseAuthors(reader) // jsonfeed 1.1 - else -> skipValue() - } + with(item) { + when (selectName(names)) { + 0 -> remoteId = nextNonEmptyString() + 1 -> link = nextNonEmptyString() + 2 -> title = nextNonEmptyString() + 3 -> contentHtml = nextNullableString() + 4 -> contentText = nextNullableString() + 5 -> description = nextNullableString() + 6 -> imageLink = nextNullableString() + 7 -> pubDate = DateUtils.parse(nextNullableString()) + 8 -> author = parseAuthor(reader) // jsonfeed 1.0 + 9 -> author = parseAuthors(reader) // jsonfeed 1.1 + else -> skipValue() } } - - validateItem(item) - item.content = if (contentHtml != null) contentHtml else contentText - if (item.pubDate == null) item.pubDate = LocalDateTime.now() - - endObject() - items += item } - endArray() - items - } catch (e: Exception) { - throw ParseException(e.message) + validateItem(item) + item.content = contentHtml ?: contentText + if (item.pubDate == null) item.pubDate = LocalDateTime.now() + + endObject() + items += item } + + endArray() + items } private fun parseAuthor(reader: JsonReader): String? { diff --git a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt index ef0beff79..949efd6f9 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt @@ -34,14 +34,15 @@ class RSS1FeedAdapter : XmlAdapter>> { konsumer.close() Pair(feed, items) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("RSS1 feed parsing failure", e) } - } private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) { - feed.url = attributes.getValueOrNull("about", - namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#") + feed.url = attributes.getValueOrNull( + localName = "about", + namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) allChildrenAutoIgnore(names) { with(feed) { @@ -49,12 +50,16 @@ class RSS1FeedAdapter : XmlAdapter>> { "title" -> name = nonNullText() "link" -> siteUrl = nonNullText() "description" -> description = nullableText() + "image" -> imageUrl = attributes.getValueOrNull( + localName = "resource", + namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) } } } } companion object { - val names = Names.of("title", "link", "description") + val names = Names.of("title", "link", "description", "image") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt index ce207bd55..4f87a2531 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1ItemAdapter.kt @@ -16,39 +16,36 @@ import java.time.LocalDateTime class RSS1ItemAdapter : XmlAdapter { override fun fromXml(konsumer: Konsumer): Item { - val item= Item() - - return try { - val authors = arrayListOf() - val about = konsumer.attributes.getValueOrNull("about", - namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#") - - item.apply { - konsumer.allChildrenAutoIgnore(names) { - when (tagName) { - "title" -> title = nonNullText() - "link" -> link = nullableText() - "dc:date" -> pubDate = DateUtils.parse(nullableText()) - "dc:creator" -> authors += nullableText() - "description" -> description = nullableTextRecursively() - "content:encoded" -> content = nullableTextRecursively() - else -> skipContents() - } + val item = Item() + + val authors = arrayListOf() + val about = konsumer.attributes.getValueOrNull( + "about", + namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) + + return item.apply { + konsumer.allChildrenAutoIgnore(names) { + when (tagName) { + "title" -> title = nonNullText() + "link" -> link = nullableText() + "dc:date" -> pubDate = DateUtils.parse(nullableText()) + "dc:creator" -> authors += nullableText() + "description" -> description = nullableTextRecursively() + "content:encoded" -> content = nullableTextRecursively() + else -> skipContents() } } - if (item.pubDate == null) item.pubDate = LocalDateTime.now() - if (item.link == null) item.link = about - ?: throw ParseException("RSS1 link or about element is required") - item.remoteId = item.link + validateItem(this) - if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull() - .joinToString(limit = AUTHORS_MAX) + if (pubDate == null) pubDate = LocalDateTime.now() + if (link == null) link = about + ?: throw ParseException("RSS1 link or about element is required") + remoteId = link - validateItem(item) - item - } catch (e: Exception) { - throw ParseException(e.message) + if (authors.filterNotNull().isNotEmpty()) author = authors.filterNotNull() + .joinToString(limit = AUTHORS_MAX) } } diff --git a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt index f6845bf99..c92a66338 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt @@ -35,6 +35,7 @@ class RSS2FeedAdapter : XmlAdapter>> { url = attributes.getValueOrNull("href") } "item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) + "image" -> imageUrl = parseImage(this@allChildrenAutoIgnore) else -> skipContents() } } @@ -45,11 +46,24 @@ class RSS2FeedAdapter : XmlAdapter>> { konsumer.close() Pair(feed, items) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("RSS2 feed parsing failure", e) } } + private fun parseImage(konsumer: Konsumer): String? = with(konsumer) { + var url: String? = null + + allChildrenAutoIgnore(Names.of("url")) { + when (tagName) { + "url" -> url = nullableText() + else -> skipContents() + } + } + + url + } + companion object { - val names = Names.of("title", "description", "link", "item") + val names = Names.of("title", "description", "link", "item", "image") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt index 7adce905a..767eaf2ed 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2ItemAdapter.kt @@ -1,9 +1,9 @@ package com.readrops.api.localfeed.rss2 import com.gitlab.mvysny.konsumexml.Konsumer -import com.gitlab.mvysny.konsumexml.KonsumerException import com.gitlab.mvysny.konsumexml.Names import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore +import com.readrops.api.localfeed.RSSMedia import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX import com.readrops.api.utils.ApiUtils @@ -20,64 +20,27 @@ class RSS2ItemAdapter : XmlAdapter { override fun fromXml(konsumer: Konsumer): Item { val item = Item() - return try { - //konsumer.checkCurrent("item") - val creators = arrayListOf() - - item.apply { - konsumer.allChildrenAutoIgnore(names) { - when (tagName) { - "title" -> title = ApiUtils.cleanText(nonNullText()) - "link" -> link = nonNullText() - "author" -> author = nullableText() - "dc:creator" -> creators += nullableText() - "pubDate" -> pubDate = DateUtils.parse(nullableText()) - "dc:date" -> pubDate = DateUtils.parse(nullableText()) - "guid" -> remoteId = nullableText() - "description" -> description = nullableTextRecursively() - "content:encoded" -> content = nullableTextRecursively() - "enclosure" -> parseEnclosure(this, item = this@apply) - "media:content" -> parseMediaContent(this, item = this@apply) - "media:group" -> parseMediaGroup(this, item = this@apply) - else -> skipContents() // for example media:description - } + val creators = arrayListOf() + + return item.apply { + konsumer.allChildrenAutoIgnore(names) { + when (tagName) { + "title" -> title = ApiUtils.cleanText(nonNullText()) + "link" -> link = nonNullText() + "author" -> author = nullableText() + "dc:creator" -> creators += nullableText() + "pubDate" -> pubDate = DateUtils.parse(nullableText()) + "dc:date" -> pubDate = DateUtils.parse(nullableText()) + "guid" -> remoteId = nullableText() + "description" -> description = nullableTextRecursively() + "content:encoded" -> content = nullableTextRecursively() + "enclosure" -> RSSMedia.parseMediaContent(this, item = this@apply) + "media:content" -> RSSMedia.parseMediaContent(this, item = this@apply) + "media:group" -> RSSMedia.parseMediaGroup(this, item = this@apply) + else -> skipContents() // for example media:description } } - - finalizeItem(item, creators) - item - } catch (e: KonsumerException) { - throw ParseException(e.message) - } - } - - private fun parseEnclosure(konsumer: Konsumer, item: Item) = with(konsumer) { - if (attributes.getValueOrNull("type") != null - && ApiUtils.isMimeImage(attributes["type"]) && item.imageLink == null) - item.imageLink = attributes.getValueOrNull("url") - } - - private fun isMediumImage(konsumer: Konsumer) = with(konsumer) { - attributes.getValueOrNull("medium") != null && ApiUtils.isMimeImage(attributes["medium"]) - } - - private fun isTypeImage(konsumer: Konsumer) = with(konsumer) { - attributes.getValueOrNull("type") != null && ApiUtils.isMimeImage(attributes["type"]) - } - - private fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) { - if ((isMediumImage(konsumer) || isTypeImage(konsumer)) && item.imageLink == null) - item.imageLink = konsumer.attributes.getValueOrNull("url") - - konsumer.skipContents() // ignore media content sub elements - } - - private fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(konsumer) { - allChildrenAutoIgnore("content") { - when (tagName) { - "media:content" -> parseMediaContent(this, item) - else -> skipContents() - } + finalizeItem(this, creators) } } @@ -98,7 +61,9 @@ class RSS2ItemAdapter : XmlAdapter { } companion object { - val names = Names.of("title", "link", "author", "creator", "pubDate", "date", - "guid", "description", "encoded", "enclosure", "content", "group") + val names = Names.of( + "title", "link", "author", "creator", "pubDate", "date", + "guid", "description", "encoded", "enclosure", "content", "group" + ) } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt b/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt index 5d586b849..fc0c6b01c 100644 --- a/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt +++ b/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt @@ -7,7 +7,6 @@ import com.readrops.api.localfeed.XmlAdapter import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder -import java.lang.Exception class OPMLAdapter : XmlAdapter>> { @@ -27,7 +26,7 @@ class OPMLAdapter : XmlAdapter>> { opml!! } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException(e) } /** diff --git a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt index 5ec352ced..9d56f43c3 100644 --- a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt +++ b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt @@ -4,42 +4,54 @@ import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.redundent.kotlin.xml.xml import java.io.InputStream import java.io.OutputStream object OPMLParser { - suspend fun read(stream: InputStream): Map> { + suspend fun read(stream: InputStream): Map> = withContext(Dispatchers.IO) { try { val adapter = OPMLAdapter() val opml = adapter.fromXml(stream.konsumeXml()) stream.close() - return opml + opml } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("OPML reading failure", e) } } - suspend fun write(foldersAndFeeds: Map>, outputStream: OutputStream) { - val opml = xml("opml") { - attribute("version", "2.0") + suspend fun write(foldersAndFeeds: Map>, outputStream: OutputStream) = + withContext(Dispatchers.IO) { + val opml = xml("opml") { + attribute("version", "2.0") - "head" { - -"Subscriptions" - } + "head" { + -"Subscriptions" + } - "body" { - for (folderAndFeeds in foldersAndFeeds) { - if (folderAndFeeds.key != null) { // feeds with folder - "outline" { - folderAndFeeds.key?.name?.let { - attribute("title", it) - attribute("text", it) - } + "body" { + for (folderAndFeeds in foldersAndFeeds) { + if (folderAndFeeds.key != null) { // feeds with folder + "outline" { + folderAndFeeds.key?.name?.let { + attribute("title", it) + attribute("text", it) + } - for (feed in folderAndFeeds.value) { + for (feed in folderAndFeeds.value) { + "outline" { + feed.name?.let { attribute("title", it) } + attribute("xmlUrl", feed.url!!) + feed.siteUrl?.let { attribute("htmlUrl", it) } + } + } + } + } else { + for (feed in folderAndFeeds.value) { // feeds without folder "outline" { feed.name?.let { attribute("title", it) } attribute("xmlUrl", feed.url!!) @@ -47,21 +59,12 @@ object OPMLParser { } } } - } else { - for (feed in folderAndFeeds.value) { // feeds without folder - "outline" { - feed.name?.let { attribute("title", it) } - attribute("xmlUrl", feed.url!!) - feed.siteUrl?.let { attribute("htmlUrl", it) } - } - } } } } - } - outputStream.write(opml.toString().toByteArray()) - outputStream.flush() - outputStream.close() - } + outputStream.write(opml.toString().toByteArray()) + outputStream.flush() + outputStream.close() + } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/Credentials.kt b/api/src/main/java/com/readrops/api/services/Credentials.kt index 92375c135..db0721f12 100644 --- a/api/src/main/java/com/readrops/api/services/Credentials.kt +++ b/api/src/main/java/com/readrops/api/services/Credentials.kt @@ -1,8 +1,7 @@ package com.readrops.api.services import com.readrops.api.services.fever.FeverCredentials -import com.readrops.api.services.freshrss.FreshRSSCredentials -import com.readrops.api.services.freshrss.FreshRSSService +import com.readrops.api.services.greader.GReaderCredentials import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials import com.readrops.api.services.nextcloudnews.NextcloudNewsService import com.readrops.db.entities.account.Account @@ -12,11 +11,11 @@ abstract class Credentials(val authorization: String?, val url: String) { companion object { fun toCredentials(account: Account): Credentials { - val endPoint = getEndPoint(account.accountType!!) + val endPoint = getEndPoint(account.type!!) - return when (account.accountType) { + return when (account.type) { AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint) - AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint) + AccountType.FRESHRSS, AccountType.GREADER -> GReaderCredentials(account.token, account.url + endPoint) AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint) else -> throw IllegalArgumentException("Unknown account type") } @@ -24,9 +23,9 @@ abstract class Credentials(val authorization: String?, val url: String) { private fun getEndPoint(accountType: AccountType): String { return when (accountType) { - AccountType.FRESHRSS -> FreshRSSService.END_POINT AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT - AccountType.FEVER -> "" + AccountType.FRESHRSS -> "api/greader.php/" + AccountType.FEVER, AccountType.GREADER -> "" else -> throw IllegalArgumentException("Unknown account type") } } diff --git a/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt b/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt index 6c6410960..09bf36548 100644 --- a/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt +++ b/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt @@ -33,7 +33,7 @@ class FeverDataSource(private val service: FeverService) { if (syncType == SyncType.INITIAL_SYNC) { return FeverSyncResult().apply { - listOf( + awaitAll( async { feverFeeds = service.getFeeds(body) }, async { folders = service.getFolders(body) }, async { @@ -58,11 +58,10 @@ class FeverDataSource(private val service: FeverService) { async { starredIds = service.getStarredItemsIds(body) }, async { favicons = service.getFavicons(body) } ) - .awaitAll() } } else { return FeverSyncResult().apply { - listOf( + awaitAll( async { folders = service.getFolders(body) }, async { feverFeeds = service.getFeeds(body) }, async { unreadIds = service.getUnreadItemsIds(body) }, @@ -90,7 +89,6 @@ class FeverDataSource(private val service: FeverService) { } } ) - .awaitAll() } } } diff --git a/api/src/main/java/com/readrops/api/services/fever/FeverService.kt b/api/src/main/java/com/readrops/api/services/fever/FeverService.kt index fd4ad3ed4..76f0f419f 100644 --- a/api/src/main/java/com/readrops/api/services/fever/FeverService.kt +++ b/api/src/main/java/com/readrops/api/services/fever/FeverService.kt @@ -39,7 +39,7 @@ interface FeverService { @Query("id") id: String) companion object { - const val END_POINT = "/api/fever.php/" + const val END_POINT = "api/fever.php/" } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverAPIAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverAPIAdapter.kt index e41ca60a8..67edc721a 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverAPIAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverAPIAdapter.kt @@ -1,12 +1,10 @@ package com.readrops.api.services.fever.adapters import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.skipField import com.readrops.api.utils.extensions.toBoolean import com.squareup.moshi.FromJson import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonReader.Token import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson @@ -21,23 +19,19 @@ class FeverAPIAdapter : JsonAdapter() { override fun fromJson(reader: JsonReader): Boolean = with(reader) { return try { beginObject() - skipField() - var authenticated = 0 - if (nextName() == "auth") { - authenticated = nextInt() - } else { - skipValue() - } - - while (peek() == Token.NAME) { - skipField() + var authenticated = false + while (hasNext()) { + when (nextName()) { + "auth" -> authenticated = nextInt().toBoolean() + else -> skipValue() + } } endObject() - authenticated.toBoolean() + authenticated } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever API parsing failure", e) } } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt index 688ebb9b8..305e688f4 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt @@ -2,7 +2,6 @@ package com.readrops.api.services.fever.adapters import android.annotation.SuppressLint import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.skipField import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader import com.squareup.moshi.ToJson @@ -19,7 +18,6 @@ class FeverFaviconsAdapter { @ToJson fun toJson(favicons: List) = "" - @OptIn(ExperimentalEncodingApi::class) @SuppressLint("CheckResult") @FromJson fun fromJson(reader: JsonReader): List = with(reader) { @@ -27,44 +25,51 @@ class FeverFaviconsAdapter { val favicons = arrayListOf() beginObject() - - repeat(3) { - skipField() - } - - nextName() // beginning of favicon array - beginArray() - while (hasNext()) { - beginObject() + when (nextName()) { + "favicons" -> { + beginArray() - var id = 0 - var data: ByteArray? = null + while (hasNext()) { + beginObject() + parseFavicon(reader)?.let { favicons += it } - while (hasNext()) { - when (selectName(NAMES)) { - 0 -> id = nextInt() - 1 -> data = Base64.decode(nextString().substringAfter("base64,")) - else -> skipValue() - } - } + endObject() + } - if (id > 0 && data != null) { - favicons += Favicon( - id = id, - data = data, - ) + endArray() + } + else -> skipValue() } - - endObject() } - endArray() endObject() - favicons } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever favicons parsing failure", e) + } + } + + @OptIn(ExperimentalEncodingApi::class) + private fun parseFavicon(reader: JsonReader): Favicon? = with(reader) { + var id = 0 + var data: ByteArray? = null + + while (hasNext()) { + when (selectName(NAMES)) { + 0 -> id = nextInt() + 1 -> data = Base64.decode(nextString().substringAfter("base64,")) + else -> skipValue() + } + } + + if (id > 0 && data != null) { + return Favicon( + id = id, + data = data, + ) + } else { + null } } diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapter.kt index ee7783599..a91f7f9ff 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapter.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString -import com.readrops.api.utils.extensions.skipField import com.readrops.db.entities.Feed import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader @@ -30,59 +29,36 @@ class FeverFeedsAdapter : JsonAdapter() { val feedsGroups = mutableMapOf>() beginObject() - - // skip based fields (api_version, auth, last_refreshed...) - repeat(3) { - skipField() - } - - nextName() // beginning of feeds array - beginArray() - while (hasNext()) { - beginObject() - - val feed = Feed() - while (hasNext()) { - with(feed) { - when (selectName(NAMES)) { - 0 -> remoteId = nextInt().toString() - 1 -> favicons[nextInt()] = remoteId!! - 2 -> name = nextNonEmptyString() - 3 -> url = nextNonEmptyString() - 4 -> siteUrl = nextNullableString() - else -> skipValue() + when (nextName()) { + "feeds" -> { + beginArray() + while (hasNext()) { + beginObject() + feeds += parseFeed(reader, favicons) + + endObject() } - } - } - feeds += feed - endObject() - } + endArray() + } + "feeds_groups" -> { + beginArray() + while (hasNext()) { + beginObject() - endArray() + val (folderId, feedsIds) = parseFeedsGroups(reader) + folderId?.let { feedsGroups[it] = feedsIds } - nextName() - beginArray() + endObject() + } - while (hasNext()) { - beginObject() - - var folderId: Int? = null - val feedsIds = mutableListOf() - while (hasNext()) { - when (selectName(JsonReader.Options.of("group_id", "feed_ids"))) { - 0 -> folderId = nextInt() - 1 -> feedsIds += nextNonEmptyString().split(",").map { it.toInt() } - else -> skipValue() + endArray() } + else -> skipValue() } - - folderId?.let { feedsGroups[it] = feedsIds } - endObject() } - endArray() endObject() FeverFeeds( @@ -91,10 +67,43 @@ class FeverFeedsAdapter : JsonAdapter() { feedsGroups = feedsGroups ) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever feeds parsing failure", e) } } + private fun parseFeed(reader: JsonReader, favicons: MutableMap): Feed = with(reader) { + val feed = Feed() + while (hasNext()) { + with(feed) { + when (selectName(NAMES)) { + 0 -> remoteId = nextInt().toString() + 1 -> favicons[nextInt()] = remoteId!! + 2 -> name = nextNonEmptyString() + 3 -> url = nextNonEmptyString() + 4 -> siteUrl = nextNullableString() + else -> skipValue() + } + } + } + + return feed + } + + private fun parseFeedsGroups(reader: JsonReader): Pair> = with(reader) { + var folderId: Int? = null + val feedsIds = mutableListOf() + + while (hasNext()) { + when (selectName(JsonReader.Options.of("group_id", "feed_ids"))) { + 0 -> folderId = nextInt() + 1 -> feedsIds += nextNonEmptyString().split(",").map { it.toInt() } + else -> skipValue() + } + } + + folderId to feedsIds + } + companion object { val NAMES: JsonReader.Options = JsonReader.Options.of("id", "favicon_id", "title", "url", "site_url") diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapter.kt index 3a4f9dc12..573f4aa33 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapter.kt @@ -3,8 +3,6 @@ package com.readrops.api.services.fever.adapters import android.annotation.SuppressLint import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString -import com.readrops.api.utils.extensions.skipField -import com.readrops.api.utils.extensions.skipToEnd import com.readrops.db.entities.Folder import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader @@ -22,38 +20,38 @@ class FeverFoldersAdapter { val folders = arrayListOf() beginObject() - - repeat(3) { - skipField() - } - - nextName() // beginning of folders array - beginArray() - while (hasNext()) { - beginObject() - - val folder = Folder() - while (hasNext()) { - with(folder) { - when (selectName(NAMES)) { - 0 -> remoteId = nextInt().toString() - 1 -> name = nextNonEmptyString() + when (nextName()) { + "groups" -> { + beginArray() + + while (hasNext()) { + beginObject() + + val folder = Folder() + while (hasNext()) { + with(folder) { + when (selectName(NAMES)) { + 0 -> remoteId = nextInt().toString() + 1 -> name = nextNonEmptyString() + } + } + } + + folders += folder + endObject() } + + endArray() } + else -> skipValue() } - - folders += folder - endObject() } - endArray() - skipToEnd() endObject() - folders } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever folders parsing failure", e) } } diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt index fa3bc9bdd..1e13007c8 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsAdapter.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString -import com.readrops.api.utils.extensions.skipField import com.readrops.api.utils.extensions.toBoolean import com.readrops.db.entities.Item import com.readrops.db.util.DateUtils @@ -24,56 +23,59 @@ class FeverItemsAdapter { val items = arrayListOf() beginObject() - while (nextName() != "items") { - skipValue() - } - - beginArray() while (hasNext()) { - beginObject() + when (nextName()) { + "items" -> { + beginArray() + while (hasNext()) { + beginObject() + items += parseItem(reader) - val item = Item() - while (hasNext()) { - with(item) { - when (selectName(NAMES)) { - 0 -> { - remoteId = if (reader.peek() == JsonReader.Token.STRING) { - nextNonEmptyString() - } else { - nextInt().toString() - } - } - 1 -> feedRemoteId = nextNonEmptyString() - 2 -> title = nextNonEmptyString() - 3 -> author = nextNullableString() - 4 -> content = nextNullableString() - 5 -> link = nextNullableString() - 6 -> isRead = nextInt().toBoolean() - 7 -> isStarred = nextInt().toBoolean() - 8 -> pubDate = DateUtils.fromEpochSeconds(nextLong()) - else -> skipValue() + endObject() } + + endArray() } + else -> skipValue() } - - items += item - endObject() - } - - endArray() - - while (peek() != JsonReader.Token.END_OBJECT) { - skipField() } endObject() - items } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever items parsing failure", e) } } + private fun parseItem(reader: JsonReader): Item = with(reader) { + val item = Item() + + while (hasNext()) { + with(item) { + when (selectName(NAMES)) { + 0 -> { + remoteId = if (reader.peek() == JsonReader.Token.STRING) { + nextNonEmptyString() + } else { + nextInt().toString() + } + } + 1 -> feedRemoteId = nextNonEmptyString() + 2 -> title = nextNonEmptyString() + 3 -> author = nextNullableString() + 4 -> content = nextNullableString() + 5 -> link = nextNullableString() + 6 -> isRead = nextInt().toBoolean() + 7 -> isStarred = nextInt().toBoolean() + 8 -> pubDate = DateUtils.fromEpochSeconds(nextLong()) + else -> skipValue() + } + } + } + + return item + } + companion object { val NAMES: JsonReader.Options = JsonReader.Options.of( "id", "feed_id", "title", "author", "html", "url", diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsIdsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsIdsAdapter.kt index 43071f48e..20d265cb6 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsIdsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverItemsIdsAdapter.kt @@ -2,7 +2,6 @@ package com.readrops.api.services.fever.adapters import android.annotation.SuppressLint import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.skipField import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader import com.squareup.moshi.ToJson @@ -17,17 +16,19 @@ class FeverItemsIdsAdapter { fun fromJson(reader: JsonReader): List = with(reader) { return try { beginObject() - repeat(3) { - skipField() - } - nextName() // (unread|saved)_item_ids field - val ids = nextString().split(",") + val ids = arrayListOf() + while (hasNext()) { + when (nextName()) { + "unread_item_ids" -> ids.addAll(nextString().split(",")) + else -> skipValue() + } + } endObject() ids } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Fever items ids parsing failure", e) } } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapter.kt b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapter.kt deleted file mode 100644 index e1258c411..000000000 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.readrops.api.services.freshrss.adapters - -import android.annotation.SuppressLint -import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.nextNonEmptyString -import com.readrops.api.utils.extensions.nextNullableString -import com.readrops.db.entities.Feed -import com.squareup.moshi.FromJson -import com.squareup.moshi.JsonReader -import com.squareup.moshi.ToJson - -class FreshRSSFeedsAdapter { - - @ToJson - fun toJson(feeds: List): String = "" - - @SuppressLint("CheckResult") - @FromJson - fun fromJson(reader: JsonReader): List { - val feeds = mutableListOf() - - return try { - reader.beginObject() - reader.nextName() // "subscriptions", beginning of the feed array - reader.beginArray() - - while (reader.hasNext()) { - reader.beginObject() - - val feed = Feed() - while (reader.hasNext()) { - with(feed) { - when (reader.selectName(NAMES)) { - 0 -> name = reader.nextNonEmptyString() - 1 -> url = reader.nextNonEmptyString() - 2 -> siteUrl = reader.nextNullableString() - 3 -> iconUrl = reader.nextNullableString() - 4 -> remoteId = reader.nextNonEmptyString() - 5 -> remoteFolderId = getCategoryId(reader) - else -> reader.skipValue() - } - } - } - - feeds += feed - reader.endObject() - } - - reader.endArray() - reader.endObject() - - feeds - } catch (e: Exception) { - throw ParseException(e.message) - } - } - - private fun getCategoryId(reader: JsonReader): String? { - var id: String? = null - reader.beginArray() - - while (reader.hasNext()) { - reader.beginObject() - - while (reader.hasNext()) { - when (reader.nextName()) { - "id" -> id = reader.nextNullableString() - else -> reader.skipValue() - } - } - - reader.endObject() - if (!id.isNullOrEmpty()) - break - } - - reader.endArray() - return id - } - - companion object { - val NAMES: JsonReader.Options = JsonReader.Options.of("title", "url", "htmlUrl", - "iconUrl", "id", "categories") - } -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapter.kt b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapter.kt deleted file mode 100644 index 8d044eb42..000000000 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapter.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.readrops.api.services.freshrss.adapters - -import android.annotation.SuppressLint -import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.nextNonEmptyString -import com.readrops.db.entities.Folder -import com.squareup.moshi.FromJson -import com.squareup.moshi.JsonReader -import com.squareup.moshi.ToJson -import java.util.* - -class FreshRSSFoldersAdapter { - - @ToJson - fun toJson(folders: List): String = "" - - @SuppressLint("CheckResult") - @FromJson - fun fromJson(reader: JsonReader): List { - val folders = mutableListOf() - - return try { - reader.beginObject() - reader.nextName() // "tags", beginning of folder array - reader.beginArray() - - while (reader.hasNext()) { - reader.beginObject() - - val folder = Folder() - var type: String? = null - - while (reader.hasNext()) { - with(folder) { - when (reader.selectName(NAMES)) { - 0 -> { - val id = reader.nextNonEmptyString() - name = StringTokenizer(id, "/") - .toList() - .last() as String - remoteId = id - } - 1 -> type = reader.nextString() - else -> reader.skipValue() - } - } - } - - if (type == "folder") // add only folders and avoid tags - folders += folder - - reader.endObject() - } - - reader.endArray() - reader.endObject() - - folders - } catch (e: Exception) { - throw ParseException(e.message) - } - } - - companion object { - val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type") - } -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt deleted file mode 100644 index ccc6c729e..000000000 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapter.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.readrops.api.services.freshrss.adapters - -import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_READ -import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_STARRED -import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.nextNonEmptyString -import com.readrops.api.utils.extensions.nextNullableString -import com.readrops.db.entities.Item -import com.readrops.db.util.DateUtils -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter - -class FreshRSSItemsAdapter : JsonAdapter>() { - - override fun toJson(writer: JsonWriter, value: List?) { - // no need of this - } - - override fun fromJson(reader: JsonReader): List? { - val items = mutableListOf() - - return try { - reader.beginObject() - while (reader.hasNext()) { - if (reader.nextName() == "items") parseItems(reader, items) else reader.skipValue() - } - - reader.endObject() - - items - } catch (e: Exception) { - throw ParseException(e.message) - } - } - - private fun parseItems(reader: JsonReader, items: MutableList) { - reader.beginArray() - - while (reader.hasNext()) { - val item = Item() - reader.beginObject() - - while (reader.hasNext()) { - with(item) { - when (reader.selectName(NAMES)) { - 0 -> remoteId = reader.nextNonEmptyString() - 1 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong()) - 2 -> title = reader.nextNonEmptyString() - 3 -> content = getContent(reader) - 4 -> link = getLink(reader) - 5 -> getStates(reader, this) - 6 -> feedRemoteId = getRemoteFeedId(reader) - 7 -> author = reader.nextNullableString() - else -> reader.skipValue() - } - } - } - - items += item - reader.endObject() - } - - reader.endArray() - } - - private fun getContent(reader: JsonReader): String? { - var content: String? = null - reader.beginObject() - - while (reader.hasNext()) { - when (reader.nextName()) { - "content" -> content = reader.nextNullableString() - else -> reader.skipValue() - } - } - - reader.endObject() - return content - } - - private fun getLink(reader: JsonReader): String? { - var href: String? = null - reader.beginArray() - - while (reader.hasNext()) { - reader.beginObject() - - while (reader.hasNext()) { - when (reader.nextName()) { - "href" -> href = reader.nextString() - else -> reader.skipValue() - } - } - - reader.endObject() - } - - reader.endArray() - return href - } - - private fun getStates(reader: JsonReader, item: Item) { - reader.beginArray() - - while (reader.hasNext()) { - when (reader.nextString()) { - GOOGLE_READ -> item.isRead = true - GOOGLE_STARRED -> item.isStarred = true - } - } - - reader.endArray() - } - - private fun getRemoteFeedId(reader: JsonReader): String? { - var remoteFeedId: String? = null - reader.beginObject() - - while (reader.hasNext()) { - when (reader.nextName()) { - "streamId" -> remoteFeedId = reader.nextString() - else -> reader.skipValue() - } - } - - reader.endObject() - return remoteFeedId - } - - companion object { - val NAMES: JsonReader.Options = JsonReader.Options.of("id", "published", "title", - "summary", "alternate", "categories", "origin", "author") - - val TAG: String = FreshRSSItemsAdapter::class.java.simpleName - } -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapter.kt b/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapter.kt deleted file mode 100644 index 988507c14..000000000 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.readrops.api.services.freshrss.adapters - -import android.annotation.SuppressLint -import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.nextNonEmptyString -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter - -class FreshRSSItemsIdsAdapter : JsonAdapter>() { - - override fun toJson(writer: JsonWriter, value: List?) { - // not useful here - } - - @SuppressLint("CheckResult") - override fun fromJson(reader: JsonReader): List? = with(reader) { - val ids = arrayListOf() - - return try { - beginObject() - nextName() - beginArray() - - while (hasNext()) { - beginObject() - - when (nextName()) { - "id" -> { - val value = nextNonEmptyString() - ids += "tag:google.com,2005:reader/item/${ - value.toLong() - .toString(16).padStart(value.length, '0') - }" - } - else -> skipValue() - } - - endObject() - } - - endArray() - - // skip continuation - if (hasNext()) { - skipName() - skipValue() - } - - endObject() - - ids - } catch (e: Exception) { - throw ParseException(e.message) - } - } - -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSCredentials.kt b/api/src/main/java/com/readrops/api/services/greader/GReaderCredentials.kt similarity index 66% rename from api/src/main/java/com/readrops/api/services/freshrss/FreshRSSCredentials.kt rename to api/src/main/java/com/readrops/api/services/greader/GReaderCredentials.kt index b8446a186..038dfc625 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSCredentials.kt +++ b/api/src/main/java/com/readrops/api/services/greader/GReaderCredentials.kt @@ -1,8 +1,8 @@ -package com.readrops.api.services.freshrss +package com.readrops.api.services.greader import com.readrops.api.services.Credentials -class FreshRSSCredentials(token: String?, url: String) : +class GReaderCredentials(token: String?, url: String) : Credentials(token?.let { AUTH_PREFIX + it }, url) { companion object { diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt b/api/src/main/java/com/readrops/api/services/greader/GReaderDataSource.kt similarity index 88% rename from api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt rename to api/src/main/java/com/readrops/api/services/greader/GReaderDataSource.kt index 137efee85..980e7c026 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSDataSource.kt +++ b/api/src/main/java/com/readrops/api/services/greader/GReaderDataSource.kt @@ -1,8 +1,8 @@ -package com.readrops.api.services.freshrss +package com.readrops.api.services.greader import com.readrops.api.services.DataSourceResult import com.readrops.api.services.SyncType -import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo +import com.readrops.api.services.greader.adapters.FreshRSSUserInfo import com.readrops.db.entities.Item import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,7 +12,7 @@ import okhttp3.MultipartBody import java.io.StringReader import java.util.Properties -class FreshRSSDataSource(private val service: FreshRSSService) { +class GReaderDataSource(private val service: GReaderService) { suspend fun login(login: String, password: String): String { val requestBody = MultipartBody.Builder() @@ -36,12 +36,12 @@ class FreshRSSDataSource(private val service: FreshRSSService) { suspend fun synchronize( syncType: SyncType, - syncData: FreshRSSSyncData, + syncData: GReaderSyncData, writeToken: String ): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) { return if (syncType == SyncType.INITIAL_SYNC) { DataSourceResult().apply { - listOf( + awaitAll( async { folders = getFolders() }, async { feeds = getFeeds() }, async { @@ -50,17 +50,17 @@ class FreshRSSDataSource(private val service: FreshRSSService) { async { starredItems = getStarredItems(MAX_STARRED_ITEMS) }, async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) }, async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) } - ).awaitAll() + ) } } else { DataSourceResult().apply { - listOf( + awaitAll( async { setItemsReadState(syncData, writeToken) }, async { setItemsStarState(syncData, writeToken) }, - ).awaitAll() + ) - listOf( + awaitAll( async { folders = getFolders() }, async { feeds = getFeeds() }, async { items = getItems(null, MAX_ITEMS, syncData.lastModified) }, @@ -69,7 +69,7 @@ class FreshRSSDataSource(private val service: FreshRSSService) { readIds = getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS) }, async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) } - ).awaitAll() + ) } } @@ -105,12 +105,13 @@ class FreshRSSDataSource(private val service: FreshRSSService) { } } - suspend fun createFeed(token: String, feedUrl: String) { - service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe") + suspend fun createFeed(token: String, feedUrl: String, folderId: String?) { + // no feed here of the folder prefix for the folder id + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe", folderId) } suspend fun deleteFeed(token: String, feedUrl: String) { - service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe") + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe", null) } suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) { @@ -129,7 +130,7 @@ class FreshRSSDataSource(private val service: FreshRSSService) { service.deleteFolder(token, folderId) } - private suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) { + private suspend fun setItemsReadState(syncData: GReaderSyncData, token: String) { if (syncData.readIds.isNotEmpty()) { setItemsReadState(true, syncData.readIds, token) } @@ -139,7 +140,7 @@ class FreshRSSDataSource(private val service: FreshRSSService) { } } - private suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) { + private suspend fun setItemsStarState(syncData: GReaderSyncData, token: String) { if (syncData.starredIds.isNotEmpty()) { setItemStarState(true, syncData.starredIds, token) } diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt b/api/src/main/java/com/readrops/api/services/greader/GReaderService.kt similarity index 56% rename from api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt rename to api/src/main/java/com/readrops/api/services/greader/GReaderService.kt index 3dfd0d55c..90030db85 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSService.kt +++ b/api/src/main/java/com/readrops/api/services/greader/GReaderService.kt @@ -1,6 +1,6 @@ -package com.readrops.api.services.freshrss +package com.readrops.api.services.greader -import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo +import com.readrops.api.services.greader.adapters.FreshRSSUserInfo import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder import com.readrops.db.entities.Item @@ -13,7 +13,7 @@ import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query -interface FreshRSSService { +interface GReaderService { @POST("accounts/ClientLogin") suspend fun login(@Body body: RequestBody?): ResponseBody @@ -31,29 +31,49 @@ interface FreshRSSService { suspend fun getFolders(): List @GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list") - suspend fun getItems(@Query("xt") excludeTarget: List?, @Query("n") max: Int, - @Query("ot") lastModified: Long?): List + suspend fun getItems( + @Query("xt") excludeTarget: List?, + @Query("n") max: Int, + @Query("ot") lastModified: Long? + ): List @GET("reader/api/0/stream/contents/user/-/state/com.google/starred") suspend fun getStarredItems(@Query("n") max: Int): List @GET("reader/api/0/stream/items/ids") - suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?, - @Query("n") max: Int): List + suspend fun getItemsIds( + @Query("xt") excludeTarget: String?, + @Query("s") includeTarget: String?, + @Query("n") max: Int + ): List @FormUrlEncoded @POST("reader/api/0/edit-tag") - suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?, - @Field("r") removeAction: String?, @Field("i") itemIds: List) + suspend fun setItemsState( + @Field("T") token: String, + @Field("a") addAction: String?, + @Field("r") removeAction: String?, + @Field("i") itemIds: List + ) @FormUrlEncoded @POST("reader/api/0/subscription/edit") - suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String) + suspend fun createOrDeleteFeed( + @Field("T") token: String, + @Field("s") feedUrl: String, + @Field("ac") action: String, + @Field("a") folderId: String? + ) @FormUrlEncoded @POST("reader/api/0/subscription/edit") - suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String, - @Field("a") folderId: String, @Field("ac") action: String) + suspend fun updateFeed( + @Field("T") token: String, + @Field("s") feedUrl: String, + @Field("t") title: String, + @Field("a") folderId: String, + @Field("ac") action: String + ) @FormUrlEncoded @POST("reader/api/0/edit-tag") @@ -61,13 +81,13 @@ interface FreshRSSService { @FormUrlEncoded @POST("reader/api/0/rename-tag") - suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String) + suspend fun updateFolder( + @Field("T") token: String, + @Field("s") folderId: String, + @Field("dest") newFolderId: String + ) @FormUrlEncoded @POST("reader/api/0/disable-tag") suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String) - - companion object { - const val END_POINT = "/api/greader.php/" - } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt b/api/src/main/java/com/readrops/api/services/greader/GReaderSyncData.kt similarity index 74% rename from api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt rename to api/src/main/java/com/readrops/api/services/greader/GReaderSyncData.kt index 80ba5ce7f..df9553309 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/FreshRSSSyncData.kt +++ b/api/src/main/java/com/readrops/api/services/greader/GReaderSyncData.kt @@ -1,6 +1,6 @@ -package com.readrops.api.services.freshrss +package com.readrops.api.services.greader -data class FreshRSSSyncData( +data class GReaderSyncData( var lastModified: Long = 0, var readIds: List = listOf(), var unreadIds: List = listOf(), diff --git a/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapter.kt b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapter.kt new file mode 100644 index 000000000..76dc162ed --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapter.kt @@ -0,0 +1,99 @@ +package com.readrops.api.services.greader.adapters + +import android.annotation.SuppressLint +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.extensions.nextNonEmptyString +import com.readrops.api.utils.extensions.nextNullableString +import com.readrops.db.entities.Feed +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonReader +import com.squareup.moshi.ToJson + +class GReaderFeedsAdapter { + + @ToJson + fun toJson(feeds: List): String = "" + + @SuppressLint("CheckResult") + @FromJson + fun fromJson(reader: JsonReader): List = with(reader) { + val feeds = mutableListOf() + + return try { + beginObject() + + while (hasNext()) { + when (nextName()) { + "subscriptions" -> { + beginArray() + + while (hasNext()) { + beginObject() + feeds += parseFeed(reader) + + endObject() + } + + endArray() + } + else -> skipValue() + } + } + + endObject() + feeds + } catch (e: Exception) { + throw ParseException("GReader feeds parsing failure", e) + } + } + + private fun parseFeed(reader: JsonReader): Feed = with(reader) { + val feed = Feed() + + while (hasNext()) { + with(feed) { + when (selectName(NAMES)) { + 0 -> name = nextNonEmptyString() + 1 -> url = nextNonEmptyString() + 2 -> siteUrl = nextNullableString() + 3 -> iconUrl = nextNullableString() + 4 -> remoteId = nextNonEmptyString() + 5 -> remoteFolderId = getCategoryId(reader) + else -> skipValue() + } + } + } + + return feed + } + + private fun getCategoryId(reader: JsonReader): String? = with(reader) { + var id: String? = null + beginArray() + + while (hasNext()) { + beginObject() + + while (hasNext()) { + when (nextName()) { + "id" -> id = nextNullableString() + else -> skipValue() + } + } + + endObject() + if (!id.isNullOrEmpty()) + break + } + + endArray() + return id + } + + companion object { + val NAMES: JsonReader.Options = JsonReader.Options.of( + "title", "url", "htmlUrl", + "iconUrl", "id", "categories" + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapter.kt b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapter.kt new file mode 100644 index 000000000..ccb85cffa --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapter.kt @@ -0,0 +1,81 @@ +package com.readrops.api.services.greader.adapters + +import android.annotation.SuppressLint +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.extensions.nextNonEmptyString +import com.readrops.db.entities.Folder +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonReader +import com.squareup.moshi.ToJson +import java.util.StringTokenizer + +class GReaderFoldersAdapter { + + @ToJson + fun toJson(folders: List): String = "" + + @SuppressLint("CheckResult") + @FromJson + fun fromJson(reader: JsonReader): List = with(reader) { + val folders = mutableListOf() + + return try { + beginObject() + while (hasNext()) { + when (nextName()) { + "tags" -> { + beginArray() + + while (hasNext()) { + beginObject() + parseFolder(reader)?.let { folders += it } + + endObject() + } + + endArray() + } + else -> skipValue() + } + } + + endObject() + folders + } catch (e: Exception) { + throw ParseException("GReader folders parsing failure", e) + } + } + + private fun parseFolder(reader: JsonReader): Folder? = with(reader) { + val folder = Folder() + var type: String? = null + + while (hasNext()) { + with(folder) { + when (selectName(NAMES)) { + 0 -> { + val id = nextNonEmptyString() + name = StringTokenizer(id, "/") + .toList() + .last() as String + remoteId = id + } + + 1 -> type = nextString() + else -> skipValue() + } + } + } + + // add only folders and avoid tags + if (type == "folder") { + folder + } else { + null + } + } + + companion object { + val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type") + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapter.kt new file mode 100644 index 000000000..940df4357 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapter.kt @@ -0,0 +1,139 @@ +package com.readrops.api.services.greader.adapters + +import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_READ +import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_STARRED +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.extensions.nextNonEmptyString +import com.readrops.api.utils.extensions.nextNullableString +import com.readrops.db.entities.Item +import com.readrops.db.util.DateUtils +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter + +class GReaderItemsAdapter : JsonAdapter>() { + + override fun toJson(writer: JsonWriter, value: List?) { + // no need of this + } + + override fun fromJson(reader: JsonReader): List = with(reader) { + val items = mutableListOf() + + return try { + beginObject() + + while (hasNext()) { + when (nextName()) { + "items" -> parseItems(reader, items) + else -> skipValue() + } + } + + endObject() + items + } catch (e: Exception) { + throw ParseException("GReader items parsing failure", e) + } + } + + private fun parseItems(reader: JsonReader, items: MutableList) = with(reader) { + beginArray() + + while (hasNext()) { + val item = Item() + beginObject() + + while (hasNext()) { + with(item) { + when (selectName(NAMES)) { + 0 -> remoteId = nextNonEmptyString() + 1 -> pubDate = DateUtils.fromEpochSeconds(nextLong()) + 2 -> title = nextNonEmptyString() + 3 -> content = getContent(reader) + 4 -> link = getLink(reader) + 5 -> getStates(reader, this) + 6 -> feedRemoteId = getRemoteFeedId(reader) + 7 -> author = nextNullableString() + else -> skipValue() + } + } + } + + items += item + endObject() + } + + endArray() + } + + private fun getContent(reader: JsonReader): String? = with(reader) { + var content: String? = null + beginObject() + + while (hasNext()) { + when (nextName()) { + "content" -> content = nextNullableString() + else -> skipValue() + } + } + + endObject() + return content + } + + private fun getLink(reader: JsonReader): String? = with(reader) { + var href: String? = null + beginArray() + + while (hasNext()) { + beginObject() + + while (hasNext()) { + when (nextName()) { + "href" -> href = nextString() + else -> skipValue() + } + } + + endObject() + } + + endArray() + return href + } + + private fun getStates(reader: JsonReader, item: Item) = with(reader) { + beginArray() + + while (hasNext()) { + when (nextString()) { + GOOGLE_READ -> item.isRead = true + GOOGLE_STARRED -> item.isStarred = true + } + } + + endArray() + } + + private fun getRemoteFeedId(reader: JsonReader): String? = with(reader) { + var remoteFeedId: String? = null + beginObject() + + while (hasNext()) { + when (nextName()) { + "streamId" -> remoteFeedId = nextString() + else -> skipValue() + } + } + + endObject() + return remoteFeedId + } + + companion object { + val NAMES: JsonReader.Options = JsonReader.Options.of( + "id", "published", "title", "summary", "alternate", "categories", "origin", "author" + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapter.kt b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapter.kt new file mode 100644 index 000000000..40a3795f3 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapter.kt @@ -0,0 +1,56 @@ +package com.readrops.api.services.greader.adapters + +import android.annotation.SuppressLint +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.extensions.nextNonEmptyString +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter + +class GReaderItemsIdsAdapter : JsonAdapter>() { + + override fun toJson(writer: JsonWriter, value: List?) { + // not useful here + } + + @SuppressLint("CheckResult") + override fun fromJson(reader: JsonReader): List? = with(reader) { + val ids = arrayListOf() + + return try { + beginObject() + while (hasNext()) { + when (nextName()) { + "itemRefs" -> { + beginArray() + + while (hasNext()) { + beginObject() + + when (nextName()) { + "id" -> { + val value = nextNonEmptyString() + ids += "tag:google.com,2005:reader/item/" + + value.toLong() + .toString(16).padStart(value.length, '0') + } + + else -> skipValue() + } + + endObject() + } + + endArray() + } + else -> skipValue() + } + } + + endObject() + ids + } catch (e: Exception) { + throw ParseException("GReader items ids parsing failure", e) + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapter.kt b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapter.kt similarity index 87% rename from api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapter.kt rename to api/src/main/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapter.kt index 607262774..20e4487d3 100644 --- a/api/src/main/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapter.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNullableString @@ -32,7 +32,7 @@ class FreshRSSUserInfoAdapter { endObject() FreshRSSUserInfo(userName) } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("GReader user info parsing failure", e) } } } diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt index 58d5f3ed8..dbf7ccac2 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSource.kt @@ -36,7 +36,7 @@ class NextcloudNewsDataSource(private val service: NextcloudNewsService) { with(CoroutineScope(Dispatchers.IO)) { return if (syncType == SyncType.INITIAL_SYNC) { DataSourceResult().apply { - listOf( + awaitAll( async { folders = getFolders() }, async { feeds = getFeeds() }, async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) }, @@ -44,20 +44,20 @@ class NextcloudNewsDataSource(private val service: NextcloudNewsService) { starredItems = getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS) } - ).awaitAll() + ) } } else { - listOf( + awaitAll( async { setItemsReadState(syncData) }, async { setItemsStarState(syncData) }, - ).awaitAll() + ) DataSourceResult().apply { - listOf( + awaitAll( async { folders = getFolders() }, async { feeds = getFeeds() }, async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) } - ).awaitAll() + ) } } } diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt index 1400d9a7f..372d3692f 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsService.kt @@ -68,6 +68,6 @@ interface NextcloudNewsService { suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map) companion object { - const val END_POINT = "/index.php/apps/news/api/v1-3/" + const val END_POINT = "index.php/apps/news/api/v1-3/" } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt index 1c4cf090c..28db52667 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFeedsAdapter.kt @@ -32,7 +32,7 @@ class NextcloudNewsFeedsAdapter { feeds } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Nextcloud News feeds parsing failure", e) } } diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt index eb890c141..264f9a92b 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsFoldersAdapter.kt @@ -46,7 +46,7 @@ class NextcloudNewsFoldersAdapter { folders } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Nextcloud News folders parsing failure", e) } } diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt index 48046cada..cce472d8c 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapter.kt @@ -3,13 +3,14 @@ package com.readrops.api.services.nextcloudnews.adapters import android.annotation.SuppressLint import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.extensions.nextNonEmptyString +import com.readrops.api.utils.extensions.nextNullableLong import com.readrops.api.utils.extensions.nextNullableString import com.readrops.db.entities.Item import com.readrops.db.util.DateUtils import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter +import java.time.LocalDateTime class NextcloudNewsItemsAdapter : JsonAdapter>() { @@ -39,9 +40,18 @@ class NextcloudNewsItemsAdapter : JsonAdapter>() { when (reader.selectName(NAMES)) { 0 -> remoteId = reader.nextInt().toString() 1 -> link = reader.nextNullableString() - 2 -> title = reader.nextNonEmptyString() + 2 -> title = reader.nextNullableString() 3 -> author = reader.nextNullableString() - 4 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong()) + 4 -> { + val value = reader.nextNullableLong() + + pubDate = if (value != null) { + DateUtils.fromEpochSeconds(value) + } else { + LocalDateTime.now() + } + } + 5 -> content = reader.nextNullableString() 6 -> enclosureMime = reader.nextNullableString() 7 -> enclosureLink = reader.nextNullableString() @@ -53,10 +63,14 @@ class NextcloudNewsItemsAdapter : JsonAdapter>() { } } - if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!)) + if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!)) { item.imageLink = enclosureLink + } + + if (item.title != null) { + items += item + } - items += item reader.endObject() } @@ -65,12 +79,14 @@ class NextcloudNewsItemsAdapter : JsonAdapter>() { items } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Nextcloud News items parsing failure", e) } } companion object { - val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author", - "pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred") + val NAMES: JsonReader.Options = JsonReader.Options.of( + "id", "url", "title", "author", + "pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred" + ) } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt index 940c18c49..06ad40f51 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsUserAdapter.kt @@ -23,7 +23,7 @@ class NextcloudNewsUserAdapter : XmlAdapter { konsumer.close() displayName!! } catch (e: Exception) { - throw ParseException(e.message) + throw ParseException("Nextcloud News user parsing failure", e) } } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/ApiUtils.kt b/api/src/main/java/com/readrops/api/utils/ApiUtils.kt index 2305323c6..c879c56ad 100644 --- a/api/src/main/java/com/readrops/api/utils/ApiUtils.kt +++ b/api/src/main/java/com/readrops/api/utils/ApiUtils.kt @@ -1,12 +1,15 @@ package com.readrops.api.utils +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType import org.jsoup.Jsoup import java.math.BigInteger import java.security.MessageDigest import java.util.regex.Pattern object ApiUtils { - const val HTML_CONTENT_TYPE = "text/html" + val MediaType.isHtml: Boolean + get() = type == "text" && subtype == "html" const val CONTENT_TYPE_HEADER = "content-type" const val ETAG_HEADER = "ETag" @@ -14,14 +17,14 @@ object ApiUtils { const val LAST_MODIFIED_HEADER = "Last-Modified" const val IF_MODIFIED_HEADER = "If-Modified-Since" - val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml") + val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml", "application/octet-stream") private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)" fun isMimeImage(type: String): Boolean = type == "image" || type == "image/jpeg" || type == "image/jpg" || type == "image/png" - fun parseContentType(header: String?): String? { + fun parseContentType(header: String): String? { val matcher = Pattern.compile(RSS_CONTENT_TYPE_REGEX) .matcher(header) return if (matcher.find()) { @@ -37,7 +40,7 @@ object ApiUtils { * @param text string to clean * @return cleaned text */ - fun cleanText(text: String?): String { + fun cleanText(text: String): String { return Jsoup.parse(text).text().trim() } @@ -47,4 +50,18 @@ object ApiUtils { return BigInteger(1, bytes).toString(16) } -} \ No newline at end of file + + fun handleRssSpecialCases(url: String): String { + val uri = url.toHttpUrlOrNull() ?: return url + + val domain = uri.host.split(".").let { it.getOrNull(it.size - 2) } + + if (domain == "youtube" || uri.host.endsWith("youtu.be")) { + return uri.queryParameter("list")?.let { + "https://www.youtube.com/feeds/videos.xml?playlist_id=$it" + } ?: url + } + + return url + } +} diff --git a/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt b/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt index f462e4a7d..0bbac9e74 100644 --- a/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt +++ b/api/src/main/java/com/readrops/api/utils/ErrorInterceptor.kt @@ -10,8 +10,7 @@ class ErrorInterceptor : Interceptor { val request = chain.request() val response = chain.proceed(request) - // TODO cover all cases - if (!response.isSuccessful && response.code != 304) { + if (!response.isSuccessful && response.code !in 300..308) { throw HttpException(response) } diff --git a/api/src/main/java/com/readrops/api/utils/HtmlParser.kt b/api/src/main/java/com/readrops/api/utils/HtmlParser.kt index 020058819..a5784b291 100644 --- a/api/src/main/java/com/readrops/api/utils/HtmlParser.kt +++ b/api/src/main/java/com/readrops/api/utils/HtmlParser.kt @@ -2,10 +2,14 @@ package com.readrops.api.utils import android.nfc.FormatException import com.readrops.api.localfeed.LocalRSSHelper +import com.readrops.api.utils.ApiUtils.isHtml +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup import org.jsoup.nodes.Document +import org.jsoup.nodes.Element data class ParsingResult( val url: String, @@ -14,75 +18,103 @@ data class ParsingResult( object HtmlParser { - suspend fun getFaviconLink(url: String, client: OkHttpClient): String? { + @Throws(FormatException::class) + suspend fun getFeedLink(url: String, client: OkHttpClient): List { val document = getHTMLHeadFromUrl(url, client) - val elements = document.select("link") - for (element in elements) { - if (element.attributes()["rel"].lowercase().contains("icon")) { - return element.absUrl("href") + return document.select("link") + .filter { element -> + val type = element.attributes()["type"] + LocalRSSHelper.isRSSType(type) + }.map { + ParsingResult( + url = it.absUrl("href"), + label = it.attributes()["title"] + ) } - } - - return null } - suspend fun getFeedLink(url: String, client: OkHttpClient): List { - val results = mutableListOf() - - val document = getHTMLHeadFromUrl(url, client) - val elements = document.select("link") + fun getFaviconLink(document: Document): String? { + val links = document.select("link") + .filter { element -> element.attributes()["rel"].contains("icon") } + .sortedWith(compareByDescending { + it.attributes()["rel"] == "apple-touch-icon" + }.thenByDescending { element -> + val sizes = element.attr("sizes") + + if (sizes.isNotEmpty()) { + try { + sizes.filter { it.isDigit() } + .toInt() + } catch (e: Exception) { + 0 + } + } else { + 0 + } + }) - for (element in elements) { - val type = element.attributes()["type"] + return links.firstOrNull() + ?.absUrl("href") + } - if (LocalRSSHelper.isRSSType(type)) { - results += ParsingResult( - url = element.absUrl("href"), - label = element.attributes()["title"] - ) + fun getFeedImage(document: Document): String? { + return document.select("meta") + .firstOrNull { element -> + val property = element.attr("property") + listOf("og:image", "twitter:image").any { it == property } } - } - - return results + ?.absUrl("content") } - private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document { - client.newCall(Request.Builder().url(url).build()).execute().use { response -> - if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE) - ) { - val body = response.body!!.source() - - val stringBuilder = StringBuilder() - var collectionStarted = false - - while (!body.exhausted()) { - val currentLine = body.readUtf8LineStrict() + fun getFeedDescription(document: Document): String? { + return document.select("meta") + .firstOrNull { element -> + val property = element.attr("property") + listOf("og:title", "twitter:title").any { it == property } + } + ?.attr("content") + } - when { - currentLine.contains("") -> { - stringBuilder.append(currentLine) - collectionStarted = true - } - currentLine.contains("") -> { - stringBuilder.append(currentLine) - break + suspend fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document = + withContext(Dispatchers.IO) { + client.newCall( + Request.Builder() + .url(url) + .build() + ).execute() + .use { response -> + val body = response.body + if (body?.contentType()?.isHtml == true) { + val stringBuilder = StringBuilder() + var collectionStarted = false + + for (currentLine in body.charStream().buffered().lineSequence()) { + when { + currentLine.contains("", ignoreCase = true) -> { + stringBuilder.append(currentLine) + collectionStarted = true + } + + currentLine.contains("", ignoreCase = true) -> { + stringBuilder.append(currentLine) + break + } + + collectionStarted -> { + stringBuilder.append(currentLine) + } + } } - collectionStarted -> { - stringBuilder.append(currentLine) + + if (!stringBuilder.contains("", ignoreCase = true) || !stringBuilder.contains("", ignoreCase = true)) { + throw FormatException("Failed to get HTML head from $url") } + + Jsoup.parse(stringBuilder.toString(), url) + } else { + throw FormatException("Response from $url is not a html file") } } - - if (!stringBuilder.contains("") || !stringBuilder.contains("")) - throw FormatException("Failed to get HTML head") - - body.close() - return Jsoup.parse(stringBuilder.toString(), url) - } else { - throw FormatException("The response is not a html file") - } } - } - } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/exceptions/ParseException.kt b/api/src/main/java/com/readrops/api/utils/exceptions/ParseException.kt index e8fe1399a..6b6189ca0 100644 --- a/api/src/main/java/com/readrops/api/utils/exceptions/ParseException.kt +++ b/api/src/main/java/com/readrops/api/utils/exceptions/ParseException.kt @@ -1,3 +1,10 @@ package com.readrops.api.utils.exceptions -class ParseException(message: String?) : Exception(message) \ No newline at end of file +class ParseException : Exception { + + constructor(message: String) : super(message) + + constructor(message: String, cause: Exception): super(message, cause) + + constructor(cause: Exception): super(cause) +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/utils/extensions/JsonReaderExtensions.kt b/api/src/main/java/com/readrops/api/utils/extensions/JsonReaderExtensions.kt index 0d1602693..926bb168a 100644 --- a/api/src/main/java/com/readrops/api/utils/extensions/JsonReaderExtensions.kt +++ b/api/src/main/java/com/readrops/api/utils/extensions/JsonReaderExtensions.kt @@ -14,6 +14,9 @@ fun JsonReader.nextNonEmptyString(): String { fun JsonReader.nextNullableInt(): Int? = if (peek() != JsonReader.Token.NULL) nextInt() else nextNull() +fun JsonReader.nextNullableLong(): Long? = + if (peek() != JsonReader.Token.NULL) nextLong() else nextNull() + fun JsonReader.skipField() { skipName() skipValue() diff --git a/api/src/main/java/com/readrops/api/utils/extensions/KonsumerExtensions.kt b/api/src/main/java/com/readrops/api/utils/extensions/KonsumerExtensions.kt index 8ff90dd2f..0c57f03e1 100644 --- a/api/src/main/java/com/readrops/api/utils/extensions/KonsumerExtensions.kt +++ b/api/src/main/java/com/readrops/api/utils/extensions/KonsumerExtensions.kt @@ -19,7 +19,7 @@ fun Konsumer.nullableText(): String? { } fun Konsumer.nullableTextRecursively(): String? { - val text = textRecursively() + val text = textRecursively(whitespace = Whitespace.preserve) return if (text.isNotEmpty()) text.trim() else null } diff --git a/api/src/test/java/com/readrops/api/MockServerExtensions.kt b/api/src/test/java/com/readrops/api/MockServerExtensions.kt index 24d7cd456..c93c58e5f 100644 --- a/api/src/test/java/com/readrops/api/MockServerExtensions.kt +++ b/api/src/test/java/com/readrops/api/MockServerExtensions.kt @@ -12,7 +12,7 @@ fun MockWebServer.enqueueOK() { ) } -fun MockWebServer.enqueueStream(stream: InputStream) { +fun MockWebServer.enqueueOKStream(stream: InputStream) { enqueue(MockResponse() .setResponseCode(HttpURLConnection.HTTP_OK) .setBody(Buffer().readFrom(stream))) diff --git a/api/src/test/java/com/readrops/api/localfeed/LocalRSSHelperTest.kt b/api/src/test/java/com/readrops/api/localfeed/LocalRSSHelperTest.kt index 5ec458e4f..ea9c98c14 100644 --- a/api/src/test/java/com/readrops/api/localfeed/LocalRSSHelperTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/LocalRSSHelperTest.kt @@ -2,9 +2,10 @@ package com.readrops.api.localfeed import com.gitlab.mvysny.konsumexml.Names import com.gitlab.mvysny.konsumexml.konsumeXml -import junit.framework.TestCase.* +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue import org.junit.Test -import java.io.ByteArrayInputStream class LocalRSSHelperTest { @@ -16,8 +17,6 @@ class LocalRSSHelperTest { LocalRSSHelper.RSSType.RSS_2) assertEquals(LocalRSSHelper.getRSSType("application/atom+xml"), LocalRSSHelper.RSSType.ATOM) - assertEquals(LocalRSSHelper.getRSSType("application/json"), - LocalRSSHelper.RSSType.JSONFEED) assertEquals(LocalRSSHelper.getRSSType("application/feed+json"), LocalRSSHelper.RSSType.JSONFEED) } diff --git a/api/src/test/java/com/readrops/api/localfeed/XmlAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/XmlAdapterTest.kt index 06b0d3e1c..74800a01f 100644 --- a/api/src/test/java/com/readrops/api/localfeed/XmlAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/XmlAdapterTest.kt @@ -3,17 +3,12 @@ package com.readrops.api.localfeed import com.readrops.api.localfeed.atom.ATOMFeedAdapter import com.readrops.api.localfeed.rss1.RSS1FeedAdapter import com.readrops.api.localfeed.rss2.RSS2FeedAdapter -import junit.framework.Assert.assertTrue import org.junit.Assert.assertThrows -import org.junit.Rule +import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.rules.ExpectedException class XmlAdapterTest { - @get:Rule - val expectedException: ExpectedException = ExpectedException.none() - @Test fun xmlFeedAdapterFactoryTest() { assertTrue(XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.RSS_1) is RSS1FeedAdapter) diff --git a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt index 18ee69c95..4cfb33577 100644 --- a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt @@ -3,7 +3,6 @@ package com.readrops.api.localfeed.atom import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.TestUtils import com.readrops.api.utils.exceptions.ParseException -import com.readrops.db.util.DateUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import org.junit.Assert.assertThrows @@ -27,13 +26,14 @@ class ATOMAdapterTest { assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom") assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop") assertEquals(description, "Here is a subtitle") + assertEquals(imageUrl, "https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png") } - with(items[0]) { + with(items.first()) { assertEquals(items.size, 4) assertEquals(title, "Add an option to open item url in custom tab") assertEquals(link, "https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac") - assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z")) + assertEquals(pubDate!!.year, 2019) assertEquals(author, "Shinokuni") assertEquals(description, "Summary") assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac") @@ -57,7 +57,7 @@ class ATOMAdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("Item title is required")) + assertTrue(exception.stackTraceToString().contains("Item title is required")) } @Test @@ -68,6 +68,17 @@ class ATOMAdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("Item link is required")) + assertTrue(exception.stackTraceToString().contains("Item link is required")) + } + + @Test + fun mediaGroupTest() { + val stream = TestUtils.loadResource("localfeed/atom/atom_item_media_group.xml") + val pair = adapter.fromXml(stream.konsumeXml()) + + with(pair.second.first()) { + assertEquals("description", text) + assertEquals("https://i3.ytimg.com/vi/.../hqdefault.jpg", imageLink) + } } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt index 9280fa836..5f02f743e 100644 --- a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt @@ -12,6 +12,7 @@ import junit.framework.TestCase.assertEquals import okio.Buffer import org.junit.Assert.assertThrows import org.junit.Test +import kotlin.test.assertTrue class JSONFeedAdapterTest { @@ -35,6 +36,7 @@ class JSONFeedAdapterTest { assertEquals(url, "http://flyingmeat.com/blog/feed.json") assertEquals(siteUrl, "http://flyingmeat.com/blog/") assertEquals(description, "News from your friends at Flying Meat.") + assertEquals(imageUrl, "https://secure.flyingmeat.com/favicon.ico") } with(items[0]) { @@ -77,7 +79,7 @@ class JSONFeedAdapterTest { adapter.fromJson(Buffer().readFrom(stream)) } - assertEquals("Item title is required", exception.message) + assertTrue(exception.stackTraceToString().contains("Item title is required")) } @Test @@ -88,7 +90,7 @@ class JSONFeedAdapterTest { adapter.fromJson(Buffer().readFrom(stream)) } - assertEquals("Item link is required", exception.message) + assertTrue(exception.stackTraceToString().contains("Item link is required")) } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt index cd1d0f726..fe75df178 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt @@ -4,9 +4,9 @@ import com.gitlab.mvysny.konsumexml.konsumeXml import com.readrops.api.TestUtils import com.readrops.api.utils.exceptions.ParseException import com.readrops.db.util.DateUtils -import junit.framework.Assert.assertEquals -import junit.framework.Assert.assertNotNull import junit.framework.TestCase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -28,6 +28,7 @@ class RSS1AdapterTest { assertEquals(url, "https://slashdot.org/") assertEquals(siteUrl, "https://slashdot.org/") assertEquals(description, "News for nerds, stuff that matters") + assertEquals(imageUrl, "https://a.fsdn.com/sd/topics/topicslashdot.gif") } with(items[0]) { @@ -71,7 +72,7 @@ class RSS1AdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("Item title is required")) + assertTrue(exception.stackTraceToString().contains("Item title is required")) } @Test @@ -82,6 +83,6 @@ class RSS1AdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("RSS1 link or about element is required")) + assertTrue(exception.stackTraceToString().contains("RSS1 link or about element is required")) } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt index 7e71d15ef..30972d75f 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt @@ -27,6 +27,7 @@ class RSS2AdapterTest { assertEquals(url, "https://news.ycombinator.com/feed/") assertEquals(siteUrl, "https://news.ycombinator.com/") assertEquals(description, "Links for the intellectually curious, ranked by readers.") + assertEquals(imageUrl, "https://news.ycombinator.com/y18.svg") } with(items[0]) { @@ -77,7 +78,7 @@ class RSS2AdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("Item title is required")) + assertTrue(exception.stackTraceToString().contains("Item title is required")) } @Test @@ -88,15 +89,15 @@ class RSS2AdapterTest { adapter.fromXml(stream.konsumeXml()) } - assertTrue(exception.message!!.contains("Item link is required")) + assertTrue(exception.stackTraceToString().contains("Item link is required")) } @Test fun enclosureTest() { val stream = TestUtils.loadResource("localfeed/rss2/rss_items_enclosure.xml") - val item = adapter.fromXml(stream.konsumeXml()).second[0] + val item = adapter.fromXml(stream.konsumeXml()).second.first() - assertEquals(item.imageLink, "https://image1.jpg") + assertEquals("https://image1.jpg", item.imageLink) } @Test @@ -111,8 +112,8 @@ class RSS2AdapterTest { @Test fun mediaGroupTest() { val stream = TestUtils.loadResource("localfeed/rss2/rss_items_media_group.xml") - val item = adapter.fromXml(stream.konsumeXml()).second[0] + val item = adapter.fromXml(stream.konsumeXml()).second.first() - assertEquals(item.imageLink, "https://image1.jpg") + assertEquals("https://image1.jpg", item.imageLink) } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt b/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt index e0fca2108..f5e65fe5f 100644 --- a/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt +++ b/api/src/test/java/com/readrops/api/opml/OPMLParserTest.kt @@ -94,5 +94,6 @@ class OPMLParserTest { assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size) inputStream.close() + file.delete() } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/CredentialsTest.kt b/api/src/test/java/com/readrops/api/services/CredentialsTest.kt index 0cab3213b..81a8a8a40 100644 --- a/api/src/test/java/com/readrops/api/services/CredentialsTest.kt +++ b/api/src/test/java/com/readrops/api/services/CredentialsTest.kt @@ -1,6 +1,6 @@ package com.readrops.api.services -import com.readrops.api.services.freshrss.FreshRSSCredentials +import com.readrops.api.services.greader.GReaderCredentials import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials import org.junit.Test import kotlin.test.assertEquals @@ -9,7 +9,7 @@ class CredentialsTest { @Test fun credentialsTest() { - val credentials = FreshRSSCredentials("token", "https://freshrss.org") + val credentials = GReaderCredentials("token", "https://freshrss.org") assertEquals(credentials.authorization!!, "GoogleLogin auth=token") assertEquals(credentials.url, "https://freshrss.org") diff --git a/api/src/test/java/com/readrops/api/services/fever/FeverDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/fever/FeverDataSourceTest.kt index 38bd11bd9..dee363bf3 100644 --- a/api/src/test/java/com/readrops/api/services/fever/FeverDataSourceTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/FeverDataSourceTest.kt @@ -3,7 +3,7 @@ package com.readrops.api.services.fever import com.readrops.api.TestUtils import com.readrops.api.apiModule import com.readrops.api.enqueueOK -import com.readrops.api.enqueueStream +import com.readrops.api.enqueueOKStream import com.readrops.api.okResponseWithBody import com.readrops.api.services.SyncType import com.readrops.api.utils.AuthInterceptor @@ -63,7 +63,7 @@ class FeverDataSourceTest : KoinTest { @Test fun loginSuccessfulTest() = runTest { val stream = TestUtils.loadResource("services/fever/successful_auth.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) assertTrue { dataSource.login("", "") } } @@ -71,7 +71,7 @@ class FeverDataSourceTest : KoinTest { @Test fun loginFailedTest() = runTest { val stream = TestUtils.loadResource("services/fever/failed_auth.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) assertFalse { dataSource.login("", "") } } diff --git a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverAPIAdapterTest.kt b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverAPIAdapterTest.kt index 11e15b17d..91d27b59b 100644 --- a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverAPIAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverAPIAdapterTest.kt @@ -4,8 +4,8 @@ import com.readrops.api.TestUtils import com.squareup.moshi.Moshi import okio.Buffer import org.junit.Test -import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue class FeverAPIAdapterTest { @@ -18,15 +18,13 @@ class FeverAPIAdapterTest { fun authenticatedTest() { val stream = TestUtils.loadResource("services/fever/successful_auth.json") - val value = adapter.fromJson(Buffer().readFrom(stream))!! - assertEquals(value, true) + assertTrue { adapter.fromJson(Buffer().readFrom(stream))!! } } @Test fun unauthenticatedTest() { val stream = TestUtils.loadResource("services/fever/unsuccessful_auth.json") - val value = adapter.fromJson(Buffer().readFrom(stream))!! - assertFalse { value } + assertFalse { adapter.fromJson(Buffer().readFrom(stream))!! } } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapterTest.kt index a2113ab9d..ce541ecb4 100644 --- a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapterTest.kt @@ -24,7 +24,7 @@ class FeverFaviconsAdapterTest { assertEquals(favicons.size, 3) - with(favicons[0]) { + with(favicons.first()) { assertEquals(id, 85) assertNotNull(data) } diff --git a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapterTest.kt index 032e69486..1a203d679 100644 --- a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFeedsAdapterTest.kt @@ -21,7 +21,7 @@ class FeverFeedsAdapterTest { assertEquals(feverFeeds.feeds.size, 1) - with(feverFeeds.feeds[0]) { + with(feverFeeds.feeds.first()) { assertEquals(name, "xda-developers") assertEquals(url, "https://www.xda-developers.com/feed/") assertEquals(siteUrl, "https://www.xda-developers.com/") diff --git a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapterTest.kt b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapterTest.kt index 366106e58..fb652fe9c 100644 --- a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverFoldersAdapterTest.kt @@ -21,7 +21,7 @@ class FeverFoldersAdapterTest { val folders = adapter.fromJson(Buffer().readFrom(stream))!! - with(folders[0]) { + with(folders.first()) { assertEquals(name, "Libre") assertEquals(remoteId, "4") } diff --git a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverItemsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverItemsAdapterTest.kt index 2fd770b97..957ff8907 100644 --- a/api/src/test/java/com/readrops/api/services/fever/adapters/FeverItemsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/fever/adapters/FeverItemsAdapterTest.kt @@ -23,7 +23,7 @@ class FeverItemsAdapterTest { val items = adapter.fromJson(Buffer().readFrom(stream))!! - with(items[0]) { + with(items.first()) { assertEquals(title, "FreshRSS 1.9.0") assertEquals(author, "Alkarex") assertEquals(link, "https://github.com/FreshRSS/FreshRSS/releases/tag/1.9.0") diff --git a/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt deleted file mode 100644 index 3fac6d898..000000000 --- a/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.readrops.api.services.freshrss - -import com.readrops.api.TestUtils -import com.readrops.api.apiModule -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okio.Buffer -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.koin.core.qualifier.named -import org.koin.dsl.module -import org.koin.test.KoinTest -import org.koin.test.KoinTestRule -import org.koin.test.get -import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory -import java.net.HttpURLConnection -import java.net.URLEncoder -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class FreshRSSDataSourceTest : KoinTest { - - private lateinit var freshRSSDataSource: FreshRSSDataSource - private val mockServer = MockWebServer() - - @get:Rule - val koinTestRule = KoinTestRule.create { - modules(apiModule, module { - single { - Retrofit.Builder() - .baseUrl("http://localhost:8080/") - .client(get()) - .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) - .build() - .create(FreshRSSService::class.java) - } - }) - } - - @Before - fun before() { - mockServer.start(8080) - freshRSSDataSource = FreshRSSDataSource(get()) - } - - @After - fun tearDown() { - mockServer.shutdown() - } - - @Test - fun loginTest() { - runBlocking { - val responseBody = TestUtils.loadResource("services/freshrss/login_response_body") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(responseBody))) - - val authString = freshRSSDataSource.login("Login", "Password") - assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString) - - val request = mockServer.takeRequest() - val requestBody = request.body.readUtf8() - - assertTrue { - requestBody.contains("name=\"Email\"") && requestBody.contains("Login") - } - - assertTrue { - requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password") - } - } - } - - @Test - fun writeTokenTest() = runBlocking { - val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(responseBody))) - - val writeToken = freshRSSDataSource.getWriteToken() - - assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken) - } - - @Test - fun userInfoTest() = runBlocking { - - } - - @Test - fun foldersTest() = runBlocking { - val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(stream))) - - val folders = freshRSSDataSource.getFolders() - assertTrue { folders.size == 1 } - } - - @Test - fun feedsTest() = runBlocking { - val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(stream))) - - val feeds = freshRSSDataSource.getFeeds() - assertTrue { feeds.size == 1 } - } - - @Test - fun itemsTest() = runBlocking { - val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(stream))) - - val items = freshRSSDataSource.getItems(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321) - assertTrue { items.size == 2 } - - val request = mockServer.takeRequest() - - with(request.requestUrl!!) { - assertEquals(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt")) - assertEquals("100", queryParameter("n")) - assertEquals("21343321321321", queryParameter("ot")) - - } - } - - @Test - fun starredItemsTest() = runBlocking { - val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(stream))) - - val items = freshRSSDataSource.getStarredItems(100) - assertTrue { items.size == 2 } - - val request = mockServer.takeRequest() - - assertEquals("100", request.requestUrl!!.queryParameter("n")) - } - - @Test - fun getItemsIdsTest() = runBlocking { - val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json") - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody(Buffer().readFrom(stream))) - - val ids = freshRSSDataSource.getItemsIds(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_READING_LIST, 100) - assertTrue { ids.size == 5 } - - val request = mockServer.takeRequest() - with(request.requestUrl!!) { - assertEquals(FreshRSSDataSource.GOOGLE_READ, queryParameter("xt")) - assertEquals(FreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s")) - assertEquals("100", queryParameter("n")) - } - } - - @Test - fun createFeedTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.createFeed("token", "https://feed.url") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } - assertTrue { contains("ac=subscribe") } - } - } - - @Test - fun deleteFeedTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.deleteFeed("token", "https://feed.url") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } - assertTrue { contains("ac=unsubscribe") } - } - } - - @Test - fun updateFeedTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } - assertTrue { contains("t=title") } - assertTrue { contains("a=folderId") } - assertTrue { contains("ac=edit") } - } - } - - @Test - fun createFolderTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.createFolder("token", "folder") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("a=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } - } - } - - @Test - fun updateFolderTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.updateFolder("token", "folderId", "folder") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("s=folderId") } - assertTrue { contains("dest=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } - } - } - - @Test - fun deleteFolderTest() = runBlocking { - mockServer.enqueue(MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK)) - - freshRSSDataSource.deleteFolder("token", "folderId") - val request = mockServer.takeRequest() - - with(request.body.readUtf8()) { - assertTrue { contains("T=token") } - assertTrue { contains("s=folderId") } - } - } -} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/greader/GReaderDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/greader/GReaderDataSourceTest.kt new file mode 100644 index 000000000..cbea39f52 --- /dev/null +++ b/api/src/test/java/com/readrops/api/services/greader/GReaderDataSourceTest.kt @@ -0,0 +1,421 @@ +package com.readrops.api.services.greader + +import com.readrops.api.TestUtils +import com.readrops.api.apiModule +import com.readrops.api.enqueueOK +import com.readrops.api.enqueueOKStream +import com.readrops.api.okResponseWithBody +import com.readrops.api.services.SyncType +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.URLEncoder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GReaderDataSourceTest : KoinTest { + + private lateinit var freshRSSDataSource: GReaderDataSource + private val mockServer = MockWebServer() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(apiModule, module { + single { + Retrofit.Builder() + .baseUrl("http://localhost:8080/") + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi")))) + .build() + .create(GReaderService::class.java) + } + }) + } + + @Before + fun before() { + mockServer.start(8080) + freshRSSDataSource = GReaderDataSource(get()) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun loginTest() = runTest { + val responseBody = TestUtils.loadResource("services/greader/login_response_body") + mockServer.enqueueOKStream(responseBody) + + val authString = freshRSSDataSource.login("Login", "Password") + assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString) + + val request = mockServer.takeRequest() + val requestBody = request.body.readUtf8() + + assertTrue { + requestBody.contains("name=\"Email\"") && requestBody.contains("Login") + } + + assertTrue { + requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password") + } + } + + @Test + fun writeTokenTest() = runTest { + val responseBody = TestUtils.loadResource("services/greader/writetoken_response_body") + mockServer.enqueueOKStream(responseBody) + + val writeToken = freshRSSDataSource.getWriteToken() + + assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken) + } + + @Test + fun userInfoTest() = runTest { + val responseBody = TestUtils.loadResource("services/greader/adapters/user_info.json") + mockServer.enqueueOKStream(responseBody) + + val userInfo = freshRSSDataSource.getUserInfo() + + assertEquals("test", userInfo.userName) + } + + @Test + fun foldersTest() = runTest { + val stream = TestUtils.loadResource("services/greader/adapters/folders.json") + mockServer.enqueueOKStream(stream) + + val folders = freshRSSDataSource.getFolders() + assertTrue { folders.size == 1 } + } + + @Test + fun feedsTest() = runTest { + val stream = TestUtils.loadResource("services/greader/adapters/feeds.json") + mockServer.enqueueOKStream(stream) + + val feeds = freshRSSDataSource.getFeeds() + assertTrue { feeds.size == 1 } + } + + @Test + fun itemsTest() = runTest { + val stream = TestUtils.loadResource("services/greader/adapters/items.json") + mockServer.enqueueOKStream(stream) + + val items = freshRSSDataSource.getItems( + excludeTargets = listOf( + GReaderDataSource.GOOGLE_READ, + GReaderDataSource.GOOGLE_STARRED + ), + max = 100, + lastModified = 21343321321321 + ) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + with(request.requestUrl!!) { + assertEquals( + listOf(GReaderDataSource.GOOGLE_READ, GReaderDataSource.GOOGLE_STARRED), + queryParameterValues("xt") + ) + assertEquals("100", queryParameter("n")) + assertEquals("21343321321321", queryParameter("ot")) + + } + } + + @Test + fun starredItemsTest() = runTest { + val stream = TestUtils.loadResource("services/greader/adapters/items.json") + mockServer.enqueueOKStream(stream) + + val items = freshRSSDataSource.getStarredItems(100) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + assertEquals("100", request.requestUrl!!.queryParameter("n")) + } + + @Test + fun getItemsIdsTest() = runTest { + val stream = TestUtils.loadResource("services/greader/adapters/items_starred_ids.json") + mockServer.enqueueOKStream(stream) + + val ids = freshRSSDataSource.getItemsIds( + excludeTarget = GReaderDataSource.GOOGLE_READ, + includeTarget = GReaderDataSource.GOOGLE_READING_LIST, + max = 100 + ) + assertTrue { ids.size == 5 } + + val request = mockServer.takeRequest() + with(request.requestUrl!!) { + assertEquals(GReaderDataSource.GOOGLE_READ, queryParameter("xt")) + assertEquals(GReaderDataSource.GOOGLE_READING_LIST, queryParameter("s")) + assertEquals("100", queryParameter("n")) + } + } + + @Test + fun createFeedTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.createFeed("token", "https://feed.url", "feed/1") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("a=feed%2F1") } + assertTrue { + contains( + "s=${ + URLEncoder.encode( + "${GReaderDataSource.FEED_PREFIX}https://feed.url", "UTF-8" + ) + }" + ) + } + assertTrue { contains("ac=subscribe") } + } + } + + @Test + fun deleteFeedTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.deleteFeed("token", "https://feed.url") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { + contains( + "s=${ + URLEncoder.encode( + "${GReaderDataSource.FEED_PREFIX}https://feed.url", + "UTF-8" + ) + }" + ) + } + assertTrue { contains("ac=unsubscribe") } + } + } + + @Test + fun updateFeedTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { + contains( + "s=${ + URLEncoder.encode( + "${GReaderDataSource.FEED_PREFIX}https://feed.url", + "UTF-8" + ) + }" + ) + } + assertTrue { contains("t=title") } + assertTrue { contains("a=folderId") } + assertTrue { contains("ac=edit") } + } + } + + @Test + fun createFolderTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.createFolder("token", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { + contains( + "a=${ + URLEncoder.encode( + "${GReaderDataSource.FOLDER_PREFIX}folder", + "UTF-8" + ) + }" + ) + } + } + } + + @Test + fun updateFolderTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.updateFolder("token", "folderId", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + assertTrue { + contains( + "dest=${ + URLEncoder.encode( + "${GReaderDataSource.FOLDER_PREFIX}folder", + "UTF-8" + ) + }" + ) + } + } + } + + @Test + fun deleteFolderTest() = runTest { + mockServer.enqueueOK() + + freshRSSDataSource.deleteFolder("token", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + } + } + + @Test + fun initialSyncTest() = runTest { + mockServer.dispatcher = object : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + with(request.path!!) { + return when { + contains("tag/list") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json")) + } + + contains("subscription/list") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json")) + } + + // items + contains("contents/user/-/state/com.google/reading-list") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json")) + } + + // starred items + contains("contents/user/-/state/com.google/starred") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json")) + } + + // unread ids & starred ids + contains("stream/items/ids") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json")) + } + + else -> MockResponse().setResponseCode(404) + } + } + } + } + + val result = + freshRSSDataSource.synchronize(SyncType.INITIAL_SYNC, GReaderSyncData(), "writeToken") + + with(result) { + assertEquals(1, folders.size) + assertEquals(1, feeds.size) + assertEquals(2, items.size) + assertEquals(2, starredItems.size) + assertEquals(5, unreadIds.size) + assertEquals(5, starredIds.size) + } + } + + @Test + fun classicSync() = runTest { + var setItemState = 0 + val ids = listOf("1", "2", "3", "4") + val lastModified = 10L + + mockServer.dispatcher = object : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + with(request.path!!) { + // printing request path before anything prevents a request being ignored and the test fail, I don't really know why + println("request: ${request.path}") + return when { + contains("0/edit-tag") -> { + setItemState++ + MockResponse().setResponseCode(200) + } + + contains("tag/list") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json")) + } + + contains("subscription/list") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json")) + } + + // items + contains("contents/user/-/state/com.google/reading-list") -> { + assertTrue { request.path!!.contains("ot=$lastModified") } + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json")) + } + + // unread & read ids + contains("stream/items/ids") -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json")) + } + + else -> MockResponse().setResponseCode(404) + } + } + } + } + + val result = freshRSSDataSource.synchronize( + syncType = SyncType.CLASSIC_SYNC, + syncData = GReaderSyncData( + lastModified = 10L, + readIds = ids, + unreadIds = ids, + starredIds = ids, + unstarredIds = ids + ), + writeToken = "writeToken" + ) + + with(result) { + assertEquals(4, setItemState) + assertEquals(1, folders.size) + assertEquals(1, feeds.size) + assertEquals(2, items.size) + assertEquals(5, unreadIds.size) + assertEquals(5, readIds.size) + assertEquals(5, starredIds.size) + } + } +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapterTest.kt similarity index 79% rename from api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapterTest.kt index 93bce16a4..67b547611 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFeedsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFeedsAdapterTest.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.readrops.api.TestUtils import com.readrops.db.entities.Feed @@ -8,18 +8,18 @@ import junit.framework.TestCase.assertEquals import okio.Buffer import org.junit.Test -class FreshRSSFeedsAdapterTest { +class GReaderFeedsAdapterTest { private val adapter = Moshi.Builder() - .add(FreshRSSFeedsAdapter()) + .add(GReaderFeedsAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Feed::class.java)) @Test fun validFeedsTest() { - val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json") + val stream = TestUtils.loadResource("services/greader/adapters/feeds.json") - val feed = adapter.fromJson(Buffer().readFrom(stream))!![0] + val feed = adapter.fromJson(Buffer().readFrom(stream))!!.first() with(feed) { assertEquals(remoteId, "feed/2") diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapterTest.kt b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapterTest.kt similarity index 72% rename from api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapterTest.kt index b1f3b3a67..4a1167c9f 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSFoldersAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderFoldersAdapterTest.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.readrops.api.TestUtils import com.readrops.db.entities.Folder @@ -8,22 +8,22 @@ import junit.framework.TestCase.assertEquals import okio.Buffer import org.junit.Test -class FreshRSSFoldersAdapterTest { +class GReaderFoldersAdapterTest { private val adapter = Moshi.Builder() - .add(FreshRSSFoldersAdapter()) + .add(GReaderFoldersAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Folder::class.java)) @Test fun validFoldersTest() { - val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json") + val stream = TestUtils.loadResource("services/greader/adapters/folders.json") val folders = adapter.fromJson(Buffer().readFrom(stream))!! assertEquals(folders.size, 1) - with(folders[0]) { + with(folders.first()) { assertEquals(name, "Blogs") assertEquals(remoteId, "user/-/label/Blogs") } diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapterTest.kt similarity index 84% rename from api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapterTest.kt index 5d9142820..371f40654 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsAdapterTest.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.readrops.api.TestUtils import com.readrops.db.entities.Item @@ -10,20 +10,20 @@ import junit.framework.TestCase.assertNotNull import okio.Buffer import org.junit.Test -class FreshRSSItemsAdapterTest { +class GReaderItemsAdapterTest { private val adapter = Moshi.Builder() - .add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter()) + .add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, Item::class.java)) @Test fun validItemsTest() { - val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") + val stream = TestUtils.loadResource("services/greader/adapters/items.json") val items = adapter.fromJson(Buffer().readFrom(stream))!! - with(items[0]) { + with(items.first()) { assertEquals(remoteId, "tag:google.com,2005:reader/item/0005c62466ee28fe") assertEquals(title, "GNOME’s Default Theme is Getting a Revamp") assertNotNull(content) diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapterTest.kt similarity index 83% rename from api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapterTest.kt index 7831c213d..efbf98323 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSItemsIdsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderItemsIdsAdapterTest.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -6,16 +6,16 @@ import junit.framework.TestCase.assertEquals import okio.Buffer import org.junit.Test -class FreshRSSItemsIdsAdapterTest { +class GReaderItemsIdsAdapterTest { private val adapter = Moshi.Builder() - .add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter()) + .add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter()) .build() .adapter>(Types.newParameterizedType(List::class.java, String::class.java)) @Test fun validIdsTest() { - val stream = javaClass.classLoader!!.getResourceAsStream("services/freshrss/adapters/items_starred_ids.json") + val stream = javaClass.classLoader!!.getResourceAsStream("services/greader/adapters/items_starred_ids.json") val ids = adapter.fromJson(Buffer().readFrom(stream))!! diff --git a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapterTest.kt b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapterTest.kt similarity index 72% rename from api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapterTest.kt rename to api/src/test/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapterTest.kt index 757f1fba2..378b46e76 100644 --- a/api/src/test/java/com/readrops/api/services/freshrss/adapters/FreshRSSUserInfoAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/greader/adapters/GReaderUserInfoAdapterTest.kt @@ -1,4 +1,4 @@ -package com.readrops.api.services.freshrss.adapters +package com.readrops.api.services.greader.adapters import com.readrops.api.TestUtils import com.squareup.moshi.Moshi @@ -6,7 +6,7 @@ import junit.framework.TestCase.assertEquals import okio.Buffer import org.junit.Test -class FreshRSSUserInfoAdapterTest { +class GReaderUserInfoAdapterTest { private val adapter = Moshi.Builder() .add(FreshRSSUserInfoAdapter()) @@ -15,7 +15,7 @@ class FreshRSSUserInfoAdapterTest { @Test fun userInfoTest() { - val stream = TestUtils.loadResource("services/freshrss/adapters/user_info.json") + val stream = TestUtils.loadResource("services/greader/adapters/user_info.json") val userInfo = adapter.fromJson(Buffer().readFrom(stream))!! diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt index 2fe6109e5..12f16222f 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt @@ -3,12 +3,17 @@ package com.readrops.api.services.nextcloudnews import com.readrops.api.TestUtils import com.readrops.api.apiModule import com.readrops.api.enqueueOK -import com.readrops.api.enqueueStream +import com.readrops.api.enqueueOKStream +import com.readrops.api.okResponseWithBody +import com.readrops.api.services.SyncType import com.readrops.db.entities.account.Account import com.squareup.moshi.Moshi import com.squareup.moshi.Types import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Before import org.junit.Rule @@ -60,7 +65,7 @@ class NextcloudNewsDataSourceTest : KoinTest { val stream = TestUtils.loadResource("services/nextcloudnews/user.xml") val account = Account(login = "login", url = mockServer.url("").toString()) - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) val displayName = nextcloudNewsDataSource.login(get(), account) val request = mockServer.takeRequest() @@ -73,7 +78,7 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun foldersTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) val folders = nextcloudNewsDataSource.getFolders() assertTrue { folders.size == 1 } @@ -82,7 +87,7 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun feedsTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) val feeds = nextcloudNewsDataSource.getFeeds() assertTrue { feeds.size == 3 } @@ -91,14 +96,20 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun itemsTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) - val items = nextcloudNewsDataSource.getItems(NextcloudNewsDataSource.ItemQueryType.ALL.value, false, 10) + val type = NextcloudNewsDataSource.ItemQueryType.ALL.value + + val items = nextcloudNewsDataSource.getItems( + type = type, + read = false, + batchSize = 10 + ) val request = mockServer.takeRequest() - assertTrue { items.size == 3 } + assertTrue { items.size == 2 } with(request.requestUrl!!) { - assertEquals("3", queryParameter("type")) + assertEquals("$type", queryParameter("type")) assertEquals("false", queryParameter("getRead")) assertEquals("10", queryParameter("batchSize")) } @@ -107,13 +118,13 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun newItemsTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) val items = nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL) val request = mockServer.takeRequest() - assertTrue { items.size == 3 } + assertTrue { items.size == 2 } with(request.requestUrl!!) { assertEquals("1512", queryParameter("lastModified")) assertEquals("3", queryParameter("type")) @@ -123,15 +134,15 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun createFeedTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) - val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", null) + val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", 100) val request = mockServer.takeRequest() assertTrue { feeds.isNotEmpty() } with(request.requestUrl!!) { assertEquals("https://news.ycombinator.com/rss", queryParameter("url")) - assertEquals(null, queryParameter("folderId")) + assertEquals("100", queryParameter("folderId")) } } @@ -152,12 +163,11 @@ class NextcloudNewsDataSourceTest : KoinTest { nextcloudNewsDataSource.changeFeedFolder(15, 18) val request = mockServer.takeRequest() - val type = - Types.newParameterizedType( - Map::class.java, - String::class.java, - Int::class.javaObjectType - ) + val type = Types.newParameterizedType( + Map::class.java, + String::class.java, + Int::class.javaObjectType + ) val adapter = moshi.adapter>(type) val body = adapter.fromJson(request.body)!! @@ -184,7 +194,7 @@ class NextcloudNewsDataSourceTest : KoinTest { @Test fun createFolderTest() = runTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") - mockServer.enqueueStream(stream) + mockServer.enqueueOKStream(stream) val folders = nextcloudNewsDataSource.createFolder("folder name") val request = mockServer.takeRequest() @@ -267,12 +277,11 @@ class NextcloudNewsDataSourceTest : KoinTest { val starRequest = mockServer.takeRequest() val unstarRequest = mockServer.takeRequest() - val type = - Types.newParameterizedType( - Map::class.java, - String::class.java, - Types.newParameterizedType(List::class.java, Int::class.javaObjectType) - ) + val type = Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(List::class.java, Int::class.javaObjectType) + ) val adapter = moshi.adapter>>(type) val starBody = adapter.fromJson(starRequest.body)!! @@ -281,4 +290,100 @@ class NextcloudNewsDataSourceTest : KoinTest { assertEquals(data.starredIds, starBody["itemIds"]) assertEquals(data.unstarredIds, unstarBody["itemIds"]) } + + @Test + fun initialSyncTest() = runTest { + mockServer.dispatcher = object : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + with(request.path!!) { + return when { + this == "/folders" -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")) + } + + this == "/feeds" -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")) + } + + contains("/items") -> { + + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json")) + } + + else -> MockResponse().setResponseCode(404) + } + } + } + } + + val result = + nextcloudNewsDataSource.synchronize(SyncType.INITIAL_SYNC, NextcloudNewsSyncData()) + + with(result) { + assertEquals(1, folders.size) + assertEquals(3, feeds.size) + assertEquals(2, items.size) + assertEquals(2, starredItems.size) + } + } + + @Test + fun classicSyncTest() = runTest { + var setItemState = 0 + val lastModified = 10L + val ids = listOf(1, 2, 3, 4) + + mockServer.dispatcher = object : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + with(request.path!!) { + // important, otherwise test fails and I don't know why + println("request: ${request.path}") + return when { + this == "/folders" -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")) + } + + this == "/feeds" -> { + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")) + } + + contains("/items/updated") -> { + assertEquals( + "$lastModified", + request.requestUrl!!.queryParameter("lastModified") + ) + MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json")) + } + + this.matches(Regex("/items/(read|unread|star|unstar)/multiple")) -> { + setItemState++ + MockResponse().setResponseCode(200) + } + + else -> MockResponse().setResponseCode(404) + } + } + } + } + + val result = nextcloudNewsDataSource.synchronize( + SyncType.CLASSIC_SYNC, + NextcloudNewsSyncData( + lastModified = lastModified, + readIds = ids, + unreadIds = ids, + starredIds = ids, + unstarredIds = ids + ) + ) + + with(result) { + assertEquals(4, setItemState) + assertEquals(1, folders.size) + assertEquals(3, feeds.size) + assertEquals(2, items.size) + } + } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt index 716acae8b..b40aed081 100644 --- a/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/adapters/NextcloudNewsItemsAdapterTest.kt @@ -21,7 +21,9 @@ class NextcloudNewsItemsAdapterTest { val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") val items = adapter.fromJson(Buffer().readFrom(stream))!! - val item = items[0] + val item = items.first() + + assertEquals(2, items.size) with(item) { assertEquals(remoteId, "3443") @@ -33,11 +35,11 @@ class NextcloudNewsItemsAdapterTest { assertEquals(isRead, false) assertEquals(isStarred, false) assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544)) - assertEquals(imageLink, null) + assertEquals(imageLink, "https://test.org/image.jpg") } with(items[1]) { - assertEquals(imageLink, "https://test.org/image.jpg") + assertEquals(imageLink, null) } } diff --git a/api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt b/api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt index 32a231ef4..442eea479 100644 --- a/api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt +++ b/api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt @@ -29,4 +29,22 @@ class ApiUtilsTest { assertEquals(value, "98f6bcd4621d373cade4e832627b4f6") } + + @Test + fun handleRssSpecialCases() { + assertEquals("https://example.com", ApiUtils.handleRssSpecialCases("https://example.com")) + assertEquals( + "https://www.youtube.com/@user", + ApiUtils.handleRssSpecialCases("https://www.youtube.com/@user") + ) + val playlistId = "qog2gifixwn3vitjneusb9xl" + assertEquals( + "https://www.youtube.com/feeds/videos.xml?playlist_id=$playlistId", + ApiUtils.handleRssSpecialCases("https://www.youtube.com/watch?v=qjshdbmlk&list=$playlistId") + ) + assertEquals( + "https://www.youtube.com/feeds/videos.xml?playlist_id=$playlistId", + ApiUtils.handleRssSpecialCases("https://youtu.be/watch?v=qjshdbmlk&list=$playlistId") + ) + } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/AuthInterceptorTest.kt b/api/src/test/java/com/readrops/api/utils/AuthInterceptorTest.kt index 7bd948780..b89530910 100644 --- a/api/src/test/java/com/readrops/api/utils/AuthInterceptorTest.kt +++ b/api/src/test/java/com/readrops/api/utils/AuthInterceptorTest.kt @@ -1,6 +1,6 @@ package com.readrops.api.utils -import com.readrops.api.services.freshrss.FreshRSSCredentials +import com.readrops.api.services.greader.GReaderCredentials import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNull import okhttp3.OkHttpClient @@ -31,7 +31,7 @@ class AuthInterceptorTest { @Test fun credentialsUrlTest() { mockServer.enqueue(MockResponse()) - interceptor.credentials = FreshRSSCredentials("token", "http://localhost:8080/rss") + interceptor.credentials = GReaderCredentials("token", "http://localhost:8080/rss") okHttpClient.newCall(Request.Builder().url(mockServer.url("/url")).build()).execute() val request = mockServer.takeRequest() diff --git a/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt b/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt index 22f243c53..f0c74c476 100644 --- a/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt +++ b/api/src/test/java/com/readrops/api/utils/ErrorInterceptorTest.kt @@ -21,6 +21,7 @@ class ErrorInterceptorTest { client = OkHttpClient.Builder() .addInterceptor(interceptor) .build() + server.start(8080) } @@ -30,10 +31,16 @@ class ErrorInterceptorTest { } @Test(expected = HttpException::class) - fun interceptorTest() { + fun interceptorErrorTest() { server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)) client.newCall(Request.Builder().url(server.url("/url")).build()).execute() - //val request = server.takeRequest() + } + + @Test + fun interceptorSuccessTest() { + server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED)) + + client.newCall(Request.Builder().url(server.url("/url")).build()).execute() } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt b/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt index 0bf410c87..0f3f0a1f0 100644 --- a/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt +++ b/api/src/test/java/com/readrops/api/utils/HtmlParserTest.kt @@ -2,16 +2,19 @@ package com.readrops.api.utils import android.nfc.FormatException import com.readrops.api.TestUtils -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okio.Buffer +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.koin.dsl.module import org.koin.test.KoinTest import org.koin.test.KoinTestRule +import org.koin.test.get import java.net.HttpURLConnection import java.util.concurrent.TimeUnit import kotlin.test.assertEquals @@ -34,91 +37,138 @@ class HtmlParserTest : KoinTest { }) } - @Test + @Before fun before() { mockServer.start() } - @Test + @After fun after() { mockServer.shutdown() } @Test - fun getFeedLinkTest() { + fun getFeedLinkTest() = runTest { val stream = TestUtils.loadResource("utils/file.html") mockServer.enqueue( MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) .setBody(Buffer().readFrom(stream)) ) - runBlocking { - val result = - HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) - - assertTrue { result.size == 1 } - assertTrue { result.first().url.endsWith("/rss") } - assertEquals("RSS", result.first().label) + val links = HtmlParser.getFeedLink(mockServer.url("/rss").toString(), get()) - } + assertTrue { links.size == 2 } + assertTrue { links.all { it.label!!.contains("The Mozilla Blog") } } } @Test(expected = FormatException::class) - fun getFeedLinkWithoutHeadTest() { + fun getFeedLinkWithoutHeadTest() = runTest { val stream = TestUtils.loadResource("utils/file_without_head.html") mockServer.enqueue( MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) .setBody(Buffer().readFrom(stream)) ) - runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) } + HtmlParser.getFeedLink(mockServer.url("/rss").toString(), get()) } @Test(expected = FormatException::class) - fun getFeedLinkNoHtmlFileTest() { + fun getFeedLinkNoHtmlFileTest() = runTest { + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml") + ) + + HtmlParser.getFeedLink(mockServer.url("/rss").toString(), get()) + } + + @Test + fun getFaviconLinkTest() = runTest { + val stream = TestUtils.loadResource("utils/file.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) + .setBody(Buffer().readFrom(stream)) + ) + + val document = HtmlParser.getHTMLHeadFromUrl(mockServer.url("/rss").toString(), get()) + val link = HtmlParser.getFaviconLink(document) + assertTrue { link!!.contains("apple-touch-icon") } + } + + @Test + fun getFaviconLinkWithoutHeadTest() = runTest { + val stream = TestUtils.loadResource("utils/file_without_icon.html") + mockServer.enqueue( MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml")) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) + .setBody(Buffer().readFrom(stream)) + ) + val document = HtmlParser.getHTMLHeadFromUrl(mockServer.url("/rss").toString(), get()) + val link = HtmlParser.getFaviconLink(document) + assertNull(link) + } - runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) } + @Test + fun getFeedImageLinkTest() = runTest { + val stream = TestUtils.loadResource("utils/file.html") + + mockServer.enqueue( + MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) + .setBody(Buffer().readFrom(stream)) + ) + + val document = HtmlParser.getHTMLHeadFromUrl(mockServer.url("/rss").toString(), get()) + val link = HtmlParser.getFeedImage(document) + + assertEquals( + "https://blog.mozilla.org/wp-content/blogs.dir/278/files/2021/02/moz_blog_header_som_002_1200x600.jpg", + link + ) } @Test - fun getFaviconLinkTest() { + fun getFeedDescriptionTest() = runTest { val stream = TestUtils.loadResource("utils/file.html") mockServer.enqueue( MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML) .setBody(Buffer().readFrom(stream)) ) - runBlocking { - val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) + val document = HtmlParser.getHTMLHeadFromUrl(mockServer.url("/rss").toString(), get()) + val description = HtmlParser.getFeedDescription(document) - assertTrue { result!!.contains("favicon.ico") } - } + assertEquals("The Mozilla Blog", description) } @Test - fun getFaviconLinkWithoutHeadTest() { - val stream = TestUtils.loadResource("utils/file_without_icon.html") + fun getFeedDescriptionNonUnicodeTest() = runTest { + val stream = TestUtils.loadResource("utils/file_cp1253.html") mockServer.enqueue( MockResponse().setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE) + .addHeader(ApiUtils.CONTENT_TYPE_HEADER, CONTENT_TYPE_HTML_CP1253) .setBody(Buffer().readFrom(stream)) ) - runBlocking { - val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) + val document = HtmlParser.getHTMLHeadFromUrl(mockServer.url("/rss").toString(), get()) + val description = HtmlParser.getFeedDescription(document) + + assertEquals("Μενέξενς", description) + } - assertNull(result) - } + private companion object { + val CONTENT_TYPE_HTML = "text/html" + val CONTENT_TYPE_HTML_CP1253 = "text/html; charset=cp1253" } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/JsonReaderExtensionsTest.kt b/api/src/test/java/com/readrops/api/utils/JsonReaderExtensionsTest.kt index 92b95a8f4..3da100d2d 100644 --- a/api/src/test/java/com/readrops/api/utils/JsonReaderExtensionsTest.kt +++ b/api/src/test/java/com/readrops/api/utils/JsonReaderExtensionsTest.kt @@ -2,6 +2,7 @@ package com.readrops.api.utils import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.extensions.nextNonEmptyString +import com.readrops.api.utils.extensions.nextNullableLong import com.readrops.api.utils.extensions.nextNullableString import com.squareup.moshi.JsonReader import junit.framework.TestCase.assertEquals @@ -85,4 +86,33 @@ class JsonReaderExtensionsTest { reader.nextNonEmptyString() } + @Test + fun nextNullableLongNormalCaseTest() { + val reader = JsonReader.of(Buffer().readFrom(""" + { + "field": "5555555555555555555" + } + """.trimIndent().byteInputStream())) + + reader.beginObject() + reader.nextName() + + assertEquals(5555555555555555555L, reader.nextNullableLong()) + reader.endObject() + } + + @Test + fun nextNullableLongNullCaseTest() { + val reader = JsonReader.of(Buffer().readFrom(""" + { + "field": null + } + """.trimIndent().byteInputStream())) + + reader.beginObject() + reader.nextName() + + assertNull(reader.nextNullableLong()) + reader.endObject() + } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/utils/KonsumerExtensionsTest.kt b/api/src/test/java/com/readrops/api/utils/KonsumerExtensionsTest.kt index 40699959e..a7e9fc2f3 100644 --- a/api/src/test/java/com/readrops/api/utils/KonsumerExtensionsTest.kt +++ b/api/src/test/java/com/readrops/api/utils/KonsumerExtensionsTest.kt @@ -27,12 +27,13 @@ class KonsumerExtensionsTest { val xml = """ description + description """.trimIndent() xml.konsumeXml().apply { val description = child("description") { nonNullText() } - assertEquals(description, "description") + assertEquals("description\n description", description) } } @@ -53,12 +54,13 @@ description val xml = """ description + description """.trimIndent() xml.konsumeXml().apply { val description = child("description") { nullableText() } - assertEquals(description, "description") + assertEquals("description\n description", description) } } @@ -79,12 +81,13 @@ description val xml = """ description + description """.trimIndent() xml.konsumeXml().apply { val description = child("description") { nullableTextRecursively() } - assertEquals(description, "description") + assertEquals("description\n description", description) } } } \ No newline at end of file diff --git a/api/src/test/resources/localfeed/atom/atom_item_media_group.xml b/api/src/test/resources/localfeed/atom/atom_item_media_group.xml new file mode 100644 index 000000000..47a9b9c34 --- /dev/null +++ b/api/src/test/resources/localfeed/atom/atom_item_media_group.xml @@ -0,0 +1,31 @@ + + + tag:github.com,2008:/readrops/Readrops/commits/develop + + + Recent Commits to Readrops:develop + 2020-09-06T21:09:59Z + Here is a subtitle + https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png + + tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac + + Add an option to open item url in custom tab + 2020-09-06T21:09:59Z + + + Shinokuni + https://github.com/Shinokuni + + + media title + + + description + + + + + + + \ No newline at end of file diff --git a/api/src/test/resources/localfeed/atom/atom_items.xml b/api/src/test/resources/localfeed/atom/atom_items.xml index 680f6f97a..2eb7e9744 100644 --- a/api/src/test/resources/localfeed/atom/atom_items.xml +++ b/api/src/test/resources/localfeed/atom/atom_items.xml @@ -6,10 +6,12 @@ Recent Commits to Readrops:develop 2020-09-06T21:09:59Z Here is a subtitle + https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac Add an option to open item url in custom tab + 2019-09-06T21:09:59Z 2020-09-06T21:09:59Z diff --git a/api/src/test/resources/localfeed/json/json_feed.json b/api/src/test/resources/localfeed/json/json_feed.json index 4e5b032fe..12babcadd 100644 --- a/api/src/test/resources/localfeed/json/json_feed.json +++ b/api/src/test/resources/localfeed/json/json_feed.json @@ -4,6 +4,7 @@ "home_page_url": "http://flyingmeat.com/blog/", "feed_url": "http://flyingmeat.com/blog/feed.json", "description": "News from your friends at Flying Meat.", + "icon": "https://secure.flyingmeat.com/favicon.ico", "author": { "name": "Gus Mueller" }, diff --git a/api/src/test/resources/localfeed/rss2/rss_feed.xml b/api/src/test/resources/localfeed/rss2/rss_feed.xml index c4702aae1..d3d578ed5 100644 --- a/api/src/test/resources/localfeed/rss2/rss_feed.xml +++ b/api/src/test/resources/localfeed/rss2/rss_feed.xml @@ -6,6 +6,11 @@ https://news.ycombinator.com/ Links for the intellectually curious, ranked by readers. + + Hacker News + https://news.ycombinator.com/y18.svg + https://news.ycombinator.com/ + Africa declared free of wild polio https://www.bbc.com/news/world-africa-53887947 diff --git a/api/src/test/resources/services/fever/feeds.json b/api/src/test/resources/services/fever/feeds.json index db6d291d9..fd8883f24 100644 --- a/api/src/test/resources/services/fever/feeds.json +++ b/api/src/test/resources/services/fever/feeds.json @@ -1,7 +1,6 @@ { "api_version": 3, "auth": 1, - "last_refreshed_on_time": 1640284745, "feeds": [ { "id": 32, @@ -13,10 +12,12 @@ "last_updated_on_time": 1640364024 } ], + "last_refreshed_on_time": 1640284745, "feeds_groups": [ { "group_id": 3, "feed_ids": "5,4" } - ] + ], + "another_field": "another_value" } \ No newline at end of file diff --git a/api/src/test/resources/services/freshrss/adapters/feeds.json b/api/src/test/resources/services/greader/adapters/feeds.json similarity index 100% rename from api/src/test/resources/services/freshrss/adapters/feeds.json rename to api/src/test/resources/services/greader/adapters/feeds.json diff --git a/api/src/test/resources/services/freshrss/adapters/folders.json b/api/src/test/resources/services/greader/adapters/folders.json similarity index 100% rename from api/src/test/resources/services/freshrss/adapters/folders.json rename to api/src/test/resources/services/greader/adapters/folders.json diff --git a/api/src/test/resources/services/freshrss/adapters/items.json b/api/src/test/resources/services/greader/adapters/items.json similarity index 100% rename from api/src/test/resources/services/freshrss/adapters/items.json rename to api/src/test/resources/services/greader/adapters/items.json diff --git a/api/src/test/resources/services/freshrss/adapters/items_starred_ids.json b/api/src/test/resources/services/greader/adapters/items_starred_ids.json similarity index 100% rename from api/src/test/resources/services/freshrss/adapters/items_starred_ids.json rename to api/src/test/resources/services/greader/adapters/items_starred_ids.json diff --git a/api/src/test/resources/services/freshrss/adapters/user_info.json b/api/src/test/resources/services/greader/adapters/user_info.json similarity index 100% rename from api/src/test/resources/services/freshrss/adapters/user_info.json rename to api/src/test/resources/services/greader/adapters/user_info.json diff --git a/api/src/test/resources/services/freshrss/login_response_body b/api/src/test/resources/services/greader/login_response_body similarity index 100% rename from api/src/test/resources/services/freshrss/login_response_body rename to api/src/test/resources/services/greader/login_response_body diff --git a/api/src/test/resources/services/freshrss/writetoken_response_body b/api/src/test/resources/services/greader/writetoken_response_body similarity index 100% rename from api/src/test/resources/services/freshrss/writetoken_response_body rename to api/src/test/resources/services/greader/writetoken_response_body diff --git a/api/src/test/resources/services/nextcloudnews/adapters/items.json b/api/src/test/resources/services/nextcloudnews/adapters/items.json index 9cb55e7bc..47fce1719 100644 --- a/api/src/test/resources/services/nextcloudnews/adapters/items.json +++ b/api/src/test/resources/services/nextcloudnews/adapters/items.json @@ -9,8 +9,8 @@ "author": "Jan Grulich (grulja)", "pubDate": 1367270544, "body": "

At first I have to say...

", - "enclosureMime": null, - "enclosureLink": null, + "enclosureMime": "image", + "enclosureLink": "https://test.org/image.jpg", "mediaThumbnail": null, "mediaDescription": null, "feedId": 67, @@ -25,12 +25,12 @@ "guid": "http://grulja.wordpress.com/?p=76", "guidHash": "3059047a572cd9cd5d0bf645faffd077", "url": "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/", - "title": "Plasma-nm after the solid sprint", + "title": "", "author": "Jan Grulich (grulja)", "pubDate": 1367270544, "body": "

At first I have to say...

", - "enclosureMime": "image", - "enclosureLink": "https://test.org/image.jpg", + "enclosureMime": null, + "enclosureLink": null, "mediaThumbnail": null, "mediaDescription": null, "feedId": 67, diff --git a/api/src/test/resources/utils/file.html b/api/src/test/resources/utils/file.html index d55ef4355..96b00af9e 100644 --- a/api/src/test/resources/utils/file.html +++ b/api/src/test/resources/utils/file.html @@ -1,601 +1,132 @@ - - - - - - - - Hacker News - - -
- - - - - - - - - - - -
- - - - - - -
Hacker News - new | past | comments | ask | show | jobs | submit - - login - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1.A Brief History of Computers (lesswrong.com)
- 31 points by zdw 1 hour ago | hide | 3 comments -
2.Consumer Software Is Expected to Be Next Fast-Growing Segment (1994) (csmonitor.com)
- 9 points by 1970-01-01 1 hour ago | hide | 1 comment -
3.MSX-DOS (wikipedia.org)
- 82 points by pavlov 6 hours ago | hide | 26 comments -
4.New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft (nytimes.com)
- 12 points by asnyder 20 minutes ago | hide | 1 comment -
5.Apple's interactive television box: Hacking the set top box System 7.1 in ROM (oldvcr.blogspot.com)
- 160 points by todsacerdoti 10 hours ago | hide | 20 comments -
6.Putting the “You” in CPU (cpu.land)
- 187 points by uneekname 10 hours ago | hide | 73 comments -
7.Botulinum toxin: Bioweapon and magic drug (nih.gov)
- 12 points by redbell 2 hours ago | hide | 10 comments -
8.Octos – HTML live wallpaper engine (github.com/underpig1)
- 85 points by underpig1 6 hours ago | hide | 23 comments -
9.More than you've ever wanted to know about errors in Rust (shuttle.rs)
- 13 points by asymmetric 2 hours ago | hide | 3 comments -
10.Embrace Complexity; Tighten Your Feedback Loops (ferd.ca)
- 27 points by lutzh 4 hours ago | hide | 1 comment -
11.AWS networking concepts in a diagram (miparnisariblog.wordpress.com)
- 171 points by mparnisari 10 hours ago | hide | 66 comments -
12.Plane – Open-source Jira alternative (plane.so)
- 240 points by prhrb 7 hours ago | hide | 93 comments -
13.Neurotechnology: Current Developments and Ethical Issues (frontiersin.org)
- 28 points by Quinzel 3 hours ago | hide | 15 comments -
14.What we talk about when we talk about System Design (maheshba.bitbucket.io)
- 166 points by scv119 11 hours ago | hide | 22 comments -
15.ElKaWe – Electrocaloric heat pumps (fraunhofer.de)
- 140 points by danans 10 hours ago | hide | 73 comments -
16.Over-grazing and desertification in the Syrian steppe root causes of war (2015) (theecologist.org)
- 64 points by joveian 6 hours ago | hide | 43 comments -
17.Redmine – open-source project management (redmine.org)
- 34 points by synergy20 2 hours ago | hide | 24 comments -
18.Google tries internet air-gap for some staff PCs (theregister.com)
- 67 points by beardyw 9 hours ago | hide | 73 comments -
19.I thought I wanted to be a professor, then I served on a hiring committee (2021) (science.org)
- 104 points by ykonstant 4 hours ago | hide | 72 comments -
20.Internet search tips (gwern.net)
- 161 points by herbertl 12 hours ago | hide | 58 comments -
21.Bayesian methods to provide probablistic solution for the Drake equation (2019) (sciencedirect.com)
- 22 points by benbreen 4 hours ago | hide | 18 comments -
22.Biotumen: Bitumen Reinvented (biofabrik.com)
- 40 points by patall 7 hours ago | hide | 11 comments -
23.Why even let users set their own passwords? (devever.net)
- 103 points by hlandau 2 hours ago | hide | 121 comments -
24.Confronting failure as a core life skill (buildinghealthier.substack.com)
- 168 points by blh75 15 hours ago | hide | 75 comments -
25.Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836) (publicdomainreview.org)
- 19 points by tintinnabula 2 hours ago | hide | discuss -
26.Bun v0.7.0 (bun.sh)
- 163 points by sshroot 9 hours ago | hide | 107 comments -
27.Simpson Fan Grows Tomacco (2003) (simpsonsarchive.com)
- 81 points by pipeline_peak 6 hours ago | hide | 55 comments -
28.Discovery: Metals can heal themselves (sandia.gov)
- 77 points by bobvanluijt 13 hours ago | hide | 24 comments -
29.Pressure and vacuum marination does not work (2016) (genuineideas.com)
- 87 points by OJFord 13 hours ago | hide | 57 comments -
30.Scientists: Fishing boats compete with whales and penguins for Antarctic krill (mongabay.com)
- 5 points by PaulHoule 1 hour ago | hide | discuss -
-
- - - - - -
-
-
- Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

-
Search:
-
-
-
- - - + + + + + + + + + + + + + + + + Home - The Mozilla Blog + + + + + + + + + + + + + + + + + + + + + + + + + + + The Mozilla Blog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/src/test/resources/utils/file_cp1253.html b/api/src/test/resources/utils/file_cp1253.html new file mode 100644 index 000000000..9025b93da --- /dev/null +++ b/api/src/test/resources/utils/file_cp1253.html @@ -0,0 +1,1315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +

The Project Gutenberg eBook of

+ +
This ebook is for the use of anyone anywhere in the United States and +most other parts of the world at no cost and with almost no restrictions +whatsoever. You may copy it, give it away or re-use it under the terms +of the Project Gutenberg License included with this ebook or online +at www.gutenberg.org. If you are not located in the United States, +you will have to check the laws of the country where you are located +before using this eBook.
+ +

Title:

+
+

Author: Plato

+ +

Translator: I. Zervos

+
+

Release date: May 5, 2012 [eBook #39633]

+ +

Language: Greek

+ +

Credits: Produced by Sophia Canoni. Book provided by Iason Konstantinides

+ +
+*** START OF THE PROJECT GUTENBERG EBOOK ͸ *** +

+

Note: The tonic system has been changed from polytonic +to monotonic, otherwise the spelling of the book has not been changed. Footnotes +have been converted to endnotes. // + +: . + . + .

+
+



+


+

+

+

+

+

+ +

+
+
+ +
+

+

+
+ . +, , , + , , + , . + , + . + + + + , , . + , + , + , + . + , + , + , ' + +. + + , , +.

+

+ + + , ' + , , + ' . + , , + + + . ' + +. , +, , + +.

+

( ), + + + , +, +.

+

389 + , , ' + . + , + , + . + . + + , + .

+

, + + , + , + , . + + + , + (, , , , +. . .) , ' + , + , , + . + , + , , +, +, , , + Pastiche curieux de l' loquence des rheteurs +du temps, .

+

+ , +.

+

+ , + , + , . + + , .

+

, + , + + . ' + +.

+

' + , , + , + , . + , , + + .

+

, + .

+

I.

+

+

+ +

+
( ).
+

+

, , ;

+

+

, , .

+

+

; + + , , +, , + ;

+

+

, , , + ' . + + + .

+

+

[ ] ;

+

+

' ' + .

+

+

, , + . +, ' , , ' + , + , + , + () + , + ' + ' . + , , + ' [ ] ' ' +, + . + ' ' , ' + ' ' + ' , + ' , , + . + . ' + ' , + , + + .

+

+

, , . + + , + [ ).

+

+

[ ], ; ' + ' + , , + + ( ) , + . ' + () ' , + , , .

+

+

[], ;

+

+

, .

+

+

, , + .

+

+

, , + , +, +, , , + .

+

' ; , , ;

+

+

' ' , +, , , + + . , + , + , ' + .

+

+

, +;

+

+

, + ' +[] . , + ' [ ) + , , + , , , +, , [ ] + .

+

+

+;

+

+

' + , .

+

+

;

+

+

, + .

+

+

. + , ' + . ' .

+

+

+.

+

+

, . ' .

+

+

. , + , ' + . . , , + .

+

' () , + , + , . + , ( ) + ( ). , , + + . + , + , + , + , +. o ; ' + , + + . + , + . , . + + . ' + [ ] +.

+

' ' ' + , , + ' , ' + + , +, [] [ ] + + . ' ' + ' ' + .

+

' , +, , , + . ' + . +, , ' + - ; + ' ' + , + ' , ' ' + , + . , + [ +]: , + ' + , () + . + + ' , +, , + ' . + + , + . [ ] + , . + ' , , + [ ] + , ' +, , + , + , , + ' , + .

+

( ) + , ' + , + ' . + , + , + .

+

, , + ' . + , , + . + ' +, . + ( ) + ' ' + ' [ ] + + [ ] + , [] . : + +.

+

+ . + , + , ܷ ' +, + , , + ' , + + + .

+

' + [ ], , + , , + , + + . + , , + + +, + , + [] [ + ] , . + ' , + . ' + , + ' + , + ' .

+

' : + + , - + . , + , , + , + [ ] , , + + ' + , , + [] + , + , + . + .

+

+ + , + , () + , + , + , + , + : + + + , ' + +(2).

+

+, + [ +]. , , + , + + , + . + + ' + [ ] + + , ' + .

+

+ , , , [ +) + + , [] + .

+

[ ] +, + ' . + , + , [ ], + [] + +[], , + . + , + , + + [ ] + + , + + . , + , + + , + .

+

, , , + + +. + [ ] + [ +] ' + , + [ ] + + [] ' , + [ ] + ' + + , + ' , + + , .

+

, , + +. (' ) + , ( ) + , , + + +( ) + , + , ( ) , + +, () + , +. ' + , + + + . ' + + [ ] , + + , +, [ ] + , + + , [ +] . +, , + , , + + + , , + + , +, .

+

' ' +, +, ' + , + + + , . + + ' , [] + + +, . , + , , + , ' + [ ], , + , , + + . ' + . [ ] + +, , + , + , , , + , + , , . + + , + + + . . + + , + .

+

' , + , , + +(1) +, + [ ]. + + + ! ' + , ' , + . + , [] + , + , + [ . ] . + , ' +. . + ' + [] ' .

+

, , + , ' + , +, ' +[] , + , , + ' + . ' + + + , []. , +, , + , + , .

+

; ' + , + [ ] + [ ] + , , + [ ] + , + + ' , . + , + , + +(3) +. + , ' +, , + +, , + [] + , , + , ' + , . + , +, [ ] + .

+

[ ], + , + [ ] [ ] + , , + +, ' +. , + + , + [ ] , + + . + +, + . +, , , + + , ' + , ' + . ' + , +, . +, +, + + + + + + +, , + .

+

+ , , + + +. + , ( ), + +. , , + + . + , + , , + [ ]: ' + , [ ], ' + ' ' + :

+

, , + +(4) +. , , ' + ' + + + , , + , . + , [ +) , , + , , + ܷ +, ' + , , + , ' + [ ] + + . ' + + + . ' , , + , , + [ ], . + , + , + , + + , + + + , + . [ + ] , + [ ] + , . + .

+

, , + , + , ' + , + [ ] ' + + + , , + , . + , . + + [] , +, + . , ' + ' , + + . + +. + ' , + + , + . , +, . + [] , . + + +, , + ' +, , + +, ' + , + + . + ' , + , + + , + + . + + , +, , + , ' [ ] +.

+

, , + . ' + , [] + [ ), [] + [], + , + . + , + + ' + , , + [ ] + + , ' + ܷ +, , + , [] + , + + . [ +] +(5) + [], , + + +(6) + , + , + ' , ' . + . + + ' +. + .

+

, , , , .

+

+

, , , + +(7) +.

+

+

' +[|.

+

+

, , + .

+

+

+

; ;

+

+

, , ' +' ' + .

+

+

. [ ], + ' +.

+

+

[].

+

+

().

+
+


+

1) , + , .

+

2) + .

+

3) .

+

4) : . + ( + ( ). + .

+

5) , + , + + . + .

+

6) : + .

+

7) +, + +. + .

+

+

+

+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3e6dd5457..1221ca75e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,6 @@
+import java.io.FileInputStream
+import java.util.Properties
+
 plugins {
     id("com.android.application")
     kotlin("android")
@@ -5,6 +8,12 @@ plugins {
     id("com.mikepenz.aboutlibraries.plugin")
 }
 
+val props = Properties().apply {
+    runCatching {
+        load(FileInputStream(rootProject.file("local.properties")))
+    }
+}
+
 
 android {
     namespace = "com.readrops.app"
@@ -12,8 +21,10 @@ android {
     defaultConfig {
         applicationId = "com.readrops.app"
 
-        versionCode = 15
-        versionName = "2.0-beta01"
+        versionCode = 21
+        versionName = "2.1.0"
+
+        testInstrumentationRunner = "com.readrops.app.ReadropsTestRunner"
     }
 
     buildTypes {
@@ -21,7 +32,10 @@ android {
             isMinifyEnabled = true
             isShrinkResources = true
 
-            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
         }
 
         debug {
@@ -41,6 +55,20 @@ android {
             applicationIdSuffix = ".beta"
             signingConfig = signingConfigs.getByName("debug")
         }
+
+        configureEach {
+            val shouldSource = name == "debug" || name == "beta"
+            val values = mapOf("url" to "https://", "login" to "", "password" to "")
+            val accounts = listOf("local", "nextcloud_news", "freshrss", "fever", "greader")
+
+            accounts.forEach { account ->
+                values.forEach { (param, default) ->
+                    val key = "debug.$account.$param"
+                    val value = if (shouldSource) props.getProperty(key, default) else default
+                    resValue("string", key, value)
+                }
+            }
+        }
     }
 
     buildFeatures {
@@ -68,11 +96,8 @@ dependencies {
     implementation(libs.datastore)
     implementation(libs.browser)
     implementation(libs.splashscreen)
+    implementation(libs.preferences)
 
-    implementation(libs.jsoup)
-
-    testImplementation(libs.junit4)
-    androidTestImplementation(libs.bundles.test)
 
     implementation(platform(libs.compose.bom))
     implementation(libs.bundles.compose)
@@ -82,19 +107,27 @@ dependencies {
     implementation(libs.bundles.coil)
 
     implementation(libs.bundles.coroutines)
-    androidTestImplementation(libs.coroutines.test)
 
     implementation(libs.bundles.room)
     implementation(libs.bundles.paging)
 
     implementation(platform(libs.koin.bom))
     implementation(libs.bundles.koin)
-    //androidTestImplementation(libs.bundles.kointest)
-    // I don't know why but those dependencies are unreachable when accessed directly from version catalog
-    androidTestImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}")
-    androidTestImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}")
-
-    androidTestImplementation(libs.okhttp.mockserver)
 
     implementation(libs.aboutlibraries.composem3)
-}
\ No newline at end of file
+    implementation(libs.jsoup)
+    implementation(libs.colorpicker)
+
+    implementation(libs.autofill)
+    implementation(libs.template)
+    implementation(libs.slf4j.android)
+
+    testImplementation(libs.coroutines.test)
+    testImplementation(libs.junit4)
+
+    androidTestImplementation(libs.coroutines.test)
+    androidTestImplementation(libs.bundles.test)
+    androidTestImplementation(libs.bundles.kointest)
+    androidTestImplementation(libs.okhttp.mockserver)
+    androidTestImplementation(libs.coil.test)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index baa75b228..5f0c387f9 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -29,7 +29,7 @@
 
 -keep class org.simpleframework.xml.** { *; }
 
--keep class com.readrops.api.services.freshrss.json.** { *; }
+-keep class com.readrops.api.services.greader.json.** { *; }
 -keep class com.readrops.api.services.nextcloudnews.json.** { *; }
 
 -keep class com.readrops.api.localfeed.** { *; }
diff --git a/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt b/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt
deleted file mode 100644
index 40801a106..000000000
--- a/app/src/androidTest/java/com/readrops/app/GetFoldersWithFeedsTest.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.readrops.app
-
-import android.content.Context
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import com.readrops.app.repositories.GetFoldersWithFeeds
-import com.readrops.db.Database
-import com.readrops.db.entities.Feed
-import com.readrops.db.entities.Folder
-import com.readrops.db.entities.Item
-import com.readrops.db.entities.account.Account
-import com.readrops.db.entities.account.AccountType
-import com.readrops.db.filters.MainFilter
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import java.time.LocalDateTime
-import org.junit.Before
-import org.junit.Test
-import kotlin.test.assertTrue
-
-class GetFoldersWithFeedsTest {
-
-    private lateinit var database: Database
-    private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds
-    private val account = Account(accountType = AccountType.LOCAL)
-
-    @Before
-    fun before() {
-        val context = ApplicationProvider.getApplicationContext()
-        database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
-
-        runTest {
-            account.id = database.accountDao().insert(account).toInt()
-
-            // inserting 3 folders
-            repeat(3) { time ->
-                database.folderDao()
-                    .insert(Folder(name = "Folder $time", accountId = account.id))
-            }
-
-            // inserting 2 feeds, not linked to any folder
-            repeat(2) { time ->
-                database.feedDao().insert(Feed(name = "Feed $time", accountId = account.id))
-            }
-
-            // inserting 2 feeds linked to first folder (Folder 0)
-            repeat(2) { time ->
-                database.feedDao()
-                    .insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
-            }
-
-            // inserting 3 unread items linked to first feed (Feed 0)
-            repeat(3) { time ->
-                database.itemDao()
-                    .insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
-            }
-
-            // insert 3 read items items linked to second feed (feed 1)
-            repeat(3) { time ->
-                database.itemDao()
-                    .insert(
-                        Item(
-                            title = "Item ${time + 3}",
-                            feedId = 3,
-                            isRead = true,
-                            pubDate = LocalDateTime.now()
-                        )
-                    )
-            }
-        }
-    }
-
-    @Test
-    fun getFoldersWithFeedsTest() = runTest {
-        getFoldersWithFeeds = GetFoldersWithFeeds(database)
-        val foldersAndFeeds =
-            getFoldersWithFeeds.get(account.id, MainFilter.ALL, account.config.useSeparateState)
-                .first()
-
-        assertTrue { foldersAndFeeds.size == 4 }
-        assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
-        assertTrue { foldersAndFeeds.entries.last().key == null }
-        assertTrue { foldersAndFeeds[null]!!.size == 2 }
-        assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
-    }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/ReadropsTestRunner.kt b/app/src/androidTest/java/com/readrops/app/ReadropsTestRunner.kt
new file mode 100644
index 000000000..ae373b75a
--- /dev/null
+++ b/app/src/androidTest/java/com/readrops/app/ReadropsTestRunner.kt
@@ -0,0 +1,17 @@
+package com.readrops.app
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+
+@Suppress("unused")
+class ReadropsTestRunner : AndroidJUnitRunner() {
+
+    override fun newApplication(
+        classLoader: ClassLoader?,
+        className: String?,
+        context: Context?
+    ): Application {
+        return super.newApplication(classLoader, TestApplication::class.java.name, context)
+    }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt b/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt
deleted file mode 100644
index 983b428a1..000000000
--- a/app/src/androidTest/java/com/readrops/app/SyncResultAnalyserTest.kt
+++ /dev/null
@@ -1,273 +0,0 @@
-package com.readrops.app
-
-import android.content.Context
-import androidx.room.Room
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.readrops.app.repositories.SyncResult
-import com.readrops.app.sync.SyncAnalyzer
-import com.readrops.db.Database
-import com.readrops.db.entities.Feed
-import com.readrops.db.entities.Item
-import com.readrops.db.entities.account.Account
-import com.readrops.db.entities.account.AccountType
-import kotlinx.coroutines.test.runTest
-import java.time.LocalDateTime
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import kotlin.test.assertNull
-
-@RunWith(AndroidJUnit4::class)
-class SyncAnalyzerTest {
-
-    private lateinit var database: Database
-    private lateinit var syncAnalyzer: SyncAnalyzer
-    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
-
-    private val nullContentException =
-        NullPointerException("Notification content shouldn't be null")
-
-    private val account1 = Account(
-        accountName = "test account 1",
-        accountType = AccountType.FRESHRSS,
-        isNotificationsEnabled = true
-    )
-
-    private val account2 = Account(
-        accountName = "test account 2",
-        accountType = AccountType.NEXTCLOUD_NEWS,
-        isNotificationsEnabled = false
-    )
-
-    private val account3 = Account(
-        accountName = "test account 3",
-        accountType = AccountType.LOCAL,
-        isNotificationsEnabled = true
-    )
-
-    @Before
-    fun setupDb() = runTest {
-        database = Room.inMemoryDatabaseBuilder(context, Database::class.java)
-            .build()
-
-        syncAnalyzer = SyncAnalyzer(context, database)
-
-        account1.id = database.accountDao().insert(account1).toInt()
-        account2.id = database.accountDao().insert(account2).toInt()
-        account3.id = database.accountDao().insert(account3).toInt()
-
-        val accountIds = listOf(account1.id, account2.id, account3.id)
-        for (i in 0..2) {
-            val feed = Feed().apply {
-                name = "feed ${i + 1}"
-                iconUrl =
-                    "https://i0.wp.com/mrmondialisation.org/wp-content/uploads/2017/05/ico_final.gif"
-                this.accountId = accountIds.find { it == (i + 1) }!!
-                isNotificationEnabled = i % 2 == 0
-            }
-
-            database.feedDao().insert(feed)
-        }
-    }
-
-    @After
-    fun closeDb() {
-        database.close()
-    }
-
-    @Test
-    fun testOneElementEveryWhere() = runTest {
-        val item = Item(
-            title = "caseOneElementEveryWhere",
-            feedId = 1,
-            remoteId = "item 1",
-            pubDate = LocalDateTime.now()
-        )
-
-        database.itemDao().insert(item)
-
-        val syncResult = SyncResult(items = listOf(item))
-
-        syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
-            assertEquals("caseOneElementEveryWhere", content.text)
-            assertEquals("feed 1", content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.accountId > 0)
-        } ?: throw nullContentException
-
-        database.itemDao().delete(item)
-    }
-
-    @Test
-    fun testTwoItemsOneFeed() = runTest {
-        val item = Item(title = "caseTwoItemsOneFeed", feedId = 1)
-        val syncResult = SyncResult(items = listOf(item, item, item))
-
-        syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
-            assertEquals(context.getString(R.string.new_items, 3), content.text)
-            assertEquals("feed 1", content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.accountId > 0)
-        } ?: throw nullContentException
-    }
-
-    @Test
-    fun testMultipleFeeds() = runTest {
-        val item = Item(feedId = 1)
-        val item2 = Item(feedId = 3)
-
-        val syncResult = SyncResult(items = listOf(item, item2))
-
-        syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
-            assertEquals(context.getString(R.string.new_items, 2), content.text)
-            assertEquals(account1.accountName, content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.accountId > 0)
-        } ?: throw nullContentException
-    }
-
-    @Test
-    fun testMultipleAccounts() = runTest {
-        val item = Item(feedId = 1)
-        val item2 = Item(feedId = 3)
-
-        val syncResult = SyncResult(items = listOf(item, item2))
-        val syncResult2 = SyncResult(items = listOf(item, item2))
-        val syncResults = mapOf(account1 to syncResult, account3 to syncResult2)
-
-        syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
-            assertEquals(context.getString(R.string.new_items, 4), content.title)
-        } ?: throw nullContentException
-    }
-
-    @Test
-    fun testAccountNotificationsDisabled() = runTest {
-        val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 1)
-        val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 1)
-
-        val syncResult = SyncResult(items = listOf(item1, item2))
-
-        val content = syncAnalyzer.getNotificationContent(mapOf(account2 to syncResult))
-        assertNull(content)
-    }
-
-    @Test
-    fun testFeedNotificationsDisabled() = runTest {
-        val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 2)
-        val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 2)
-
-        val syncResult = SyncResult(items = listOf(item1, item2))
-        val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
-        assertNull(content)
-    }
-
-    @Test
-    fun testTwoAccountsWithOneAccountNotificationsEnabled() = runTest {
-        val item1 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
-            feedId = 1,
-            remoteId = "remoteId 1",
-            pubDate = LocalDateTime.now()
-        )
-
-        val item2 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
-            feedId = 3
-        )
-
-        val item3 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
-            feedId = 3
-        )
-
-        database.itemDao().insert(item1)
-
-        val syncResult1 = SyncResult(items = listOf(item1))
-        val syncResult2 = SyncResult(items = listOf(item2, item3))
-
-        val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
-
-        syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
-            assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
-            assertEquals("feed 1", content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.item != null)
-        } ?: throw nullContentException
-
-        database.itemDao().delete(item1)
-    }
-
-    @Test
-    fun testTwoAccountsWithOneFeedNotificationEnabled() = runTest {
-        val item1 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
-            feedId = 1,
-            remoteId = "remoteId 1",
-            pubDate = LocalDateTime.now()
-        )
-
-        val item2 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
-            feedId = 2
-        )
-
-        val item3 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
-            feedId = 2
-        )
-
-        database.itemDao().insert(item1)
-
-        val syncResult1 = SyncResult(items = listOf(item1))
-        val syncResult2 = SyncResult(items = listOf(item2, item3))
-
-        val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
-
-        syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
-            assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
-            assertEquals("feed 1", content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.item != null)
-        } ?: throw nullContentException
-
-        database.itemDao().delete(item1)
-    }
-
-
-    @Test
-    fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() = runTest {
-        val item1 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
-            feedId = 1,
-            remoteId = "remoteId 1",
-            pubDate = LocalDateTime.now()
-        )
-
-        val item2 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
-            feedId = 2
-        )
-
-        val item3 = Item(
-            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
-            feedId = 2
-        )
-
-        database.itemDao().insert(item1)
-
-        val syncResult = SyncResult(items = listOf(item1, item2, item3))
-        syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
-            assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
-            assertEquals("feed 1", content.title)
-            assertTrue(content.largeIcon != null)
-            assertTrue(content.item != null)
-            assertTrue(content.accountId > 0)
-        } ?: throw nullContentException
-
-        database.itemDao().delete(item1)
-    }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/TestApplication.kt b/app/src/androidTest/java/com/readrops/app/TestApplication.kt
new file mode 100644
index 000000000..ff584d8f1
--- /dev/null
+++ b/app/src/androidTest/java/com/readrops/app/TestApplication.kt
@@ -0,0 +1,47 @@
+package com.readrops.app
+
+import android.app.Application
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import coil3.ColorImage
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import coil3.annotation.ExperimentalCoilApi
+import coil3.test.FakeImageLoaderEngine
+import coil3.util.DebugLogger
+import coil3.util.Logger
+
+class TestApplication : Application(), SingletonImageLoader.Factory {
+
+    override fun onCreate() {
+        super.onCreate()
+
+        /*startKoin {
+            androidLogger(Level.INFO)
+            androidContext(this@TestApplication)
+
+            modules(
+                module {
+                    single {
+                        Room.inMemoryDatabaseBuilder(this@TestApplication, Database::class.java)
+                            .build()
+                    }
+                },
+                apiModule, appModule
+            )
+        }*/
+    }
+
+    @OptIn(ExperimentalCoilApi::class)
+    override fun newImageLoader(context: PlatformContext): ImageLoader {
+        val fakeEngine = FakeImageLoaderEngine.Builder()
+            .default(ColorImage(Color.Companion.Blue.toArgb(), width = 300, height = 300))
+            .build()
+
+        return ImageLoader.Builder(this)
+            .logger(DebugLogger(minLevel = Logger.Level.Debug))
+            .components { add(fakeEngine) }
+            .build()
+    }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/repositories/GetFoldersWithFeedsTest.kt b/app/src/androidTest/java/com/readrops/app/repositories/GetFoldersWithFeedsTest.kt
new file mode 100644
index 000000000..06bf1ed38
--- /dev/null
+++ b/app/src/androidTest/java/com/readrops/app/repositories/GetFoldersWithFeedsTest.kt
@@ -0,0 +1,231 @@
+package com.readrops.app.repositories
+
+import com.readrops.app.testutil.ReadropsTestRule
+import com.readrops.db.Database
+import com.readrops.db.entities.Feed
+import com.readrops.db.entities.Folder
+import com.readrops.db.entities.Item
+import com.readrops.db.entities.ItemState
+import com.readrops.db.entities.account.Account
+import com.readrops.db.entities.account.AccountType
+import com.readrops.db.filters.MainFilter
+import junit.framework.Assert.assertEquals
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.koin.test.KoinTest
+import org.koin.test.inject
+import java.time.LocalDateTime
+import kotlin.test.assertNull
+
+class GetFoldersWithFeedsTest : KoinTest {
+
+    private val database: Database by inject()
+    private val getFoldersWithFeeds: GetFoldersWithFeeds by inject()
+    private val account = Account(type = AccountType.LOCAL)
+
+    @get:Rule
+    val koinTest = ReadropsTestRule()
+
+    @Before
+    fun before() = runTest {
+        account.id = database.accountDao().insert(account).toInt()
+
+        // inserting 3 folders (folder 0, folder 1, folder 2)
+        repeat(3) { time ->
+            database.folderDao()
+                .insert(Folder(name = "Folder $time", accountId = account.id))
+        }
+
+        // inserting 2 feeds, not linked to any folder (feed 0, feed 1)
+        repeat(2) { time ->
+            database.feedDao().insert(Feed(name = "Feed $time", accountId = account.id))
+        }
+
+        // inserting 2 feeds linked to folder 0
+        repeat(2) { time ->
+            database.feedDao()
+                .insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
+        }
+
+        // inserting 3 unread items linked to feed 0
+        repeat(3) { time ->
+            database.itemDao()
+                .insert(
+                    Item(
+                        title = "Item $time",
+                        feedId = 1,
+                        pubDate = if (time % 2 != 0) {
+                            LocalDateTime.now()
+                        } else {
+                            LocalDateTime.now().minusMonths(1L)
+                        },
+                        isStarred = time % 2 == 0
+                    )
+                )
+        }
+
+        // insert 3 read items items linked to feed 2
+        repeat(3) { time ->
+            database.itemDao()
+                .insert(
+                    Item(
+                        title = "Item ${time + 3}",
+                        feedId = 3,
+                        isRead = true,
+                        pubDate = LocalDateTime.now()
+                    )
+                )
+        }
+
+        // insert 4 unread items linked to feed 3
+        repeat(4) { time ->
+            val item = Item(
+                title = "Item ${time + 3}",
+                feedId = 4,
+                isRead = true,
+                pubDate = if (time % 2 == 0) {
+                    LocalDateTime.now()
+                } else {
+                    LocalDateTime.now().minusMonths(1L)
+                },
+                remoteId = "remote/$time"
+            )
+            database.itemDao().insert(item)
+            database.itemStateDao().insert(
+                ItemState(
+                    read = false,
+                    starred = time % 2 == 0,
+                    remoteId = "remote/$time",
+                    accountId = account.id
+                )
+            )
+        }
+
+        // folder 0 -> (feed 2, feed 3)
+        // folder 1 -> null
+        // folder 2 -> null
+        // null -> (feed 0, feed 1)
+
+        // feed 0 -> 3 unread items, 2 starred items, 1 new item
+        // feed 1 -> null
+        // feed 2 -> 3 read items
+        // feed 3 -> separate state: 4 unread items, 2 starred items, 2 new items
+    }
+
+    @Test
+    fun defaultCaseTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.ALL,
+            useSeparateState = false,
+            hideReadFeeds = false
+        ).first()
+
+        assertEquals(4, foldersAndFeeds.size)
+        assertEquals(2, foldersAndFeeds.entries.first().value.size)
+        assertNull(foldersAndFeeds.entries.last().key)
+        assertEquals(2, foldersAndFeeds[null]!!.size)
+        assertEquals(3, foldersAndFeeds[null]!!.first().unreadCount)
+    }
+
+    @Test
+    fun separateStateTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.ALL,
+            useSeparateState = true,
+            hideReadFeeds = false
+        ).first()
+        val feed = foldersAndFeeds.values.flatten().first { it.id == 4 }
+
+        assertEquals(4, feed.unreadCount)
+    }
+
+
+    @Test
+    fun hideReadFeedsTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.ALL,
+            useSeparateState = false,
+            hideReadFeeds = true
+        ).first()
+
+        assertEquals(1, foldersAndFeeds.size)
+        assertNull(foldersAndFeeds.entries.first().key)
+        assertEquals(1, foldersAndFeeds.entries.first().value.size)
+        assertEquals(3, foldersAndFeeds.entries.first().value.first().unreadCount)
+    }
+
+    @Test
+    fun hideReadFeedsWithSeparateStateTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.ALL,
+            useSeparateState = true,
+            hideReadFeeds = true
+        ).first()
+
+        val feed = foldersAndFeeds.values.flatten().first { it.id == 4 }
+
+        assertEquals(1, foldersAndFeeds.size)
+        assertEquals(1, foldersAndFeeds.values.flatten().size)
+        assertEquals(4, feed.unreadCount)
+    }
+
+    @Test
+    fun starsFilterTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.STARS,
+            useSeparateState = false,
+            hideReadFeeds = true
+        ).first()
+
+        assertEquals(1, foldersAndFeeds.size)
+        assertEquals(2, foldersAndFeeds.values.flatten().sumOf { it.unreadCount })
+    }
+
+    @Test
+    fun starsFilterSeparateStateTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.STARS,
+            useSeparateState = true,
+            hideReadFeeds = true
+        ).first()
+
+        assertEquals(1, foldersAndFeeds.size)
+        assertEquals(2, foldersAndFeeds.values.flatten().sumOf { it.unreadCount })
+    }
+
+    @Test
+    fun newFilterTest() = runTest {
+        val foldersAndFeeds = getFoldersWithFeeds.get(
+            accountId = account.id,
+            mainFilter = MainFilter.NEW,
+            useSeparateState = false,
+            hideReadFeeds = true
+        ).first()
+
+        assertEquals(1, foldersAndFeeds.size)
+        assertEquals(1, foldersAndFeeds.values.flatten().sumOf { it.unreadCount })
+    }
+
+    @Test
+    fun newItemsUnreadCountTest() = runTest {
+        val count = getFoldersWithFeeds.getNewItemsUnreadCount(1, useSeparateState = false).first()
+
+        assertEquals(1, count)
+    }
+
+    @Test
+    fun newItemsUnreadCountSeparateStateTest() = runTest {
+        val count = getFoldersWithFeeds.getNewItemsUnreadCount(1, useSeparateState = true).first()
+
+        assertEquals(2, count)
+    }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt b/app/src/androidTest/java/com/readrops/app/repositories/LocalRSSRepositoryTest.kt
similarity index 67%
rename from app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt
rename to app/src/androidTest/java/com/readrops/app/repositories/LocalRSSRepositoryTest.kt
index 8b87f364a..222b7f8ed 100644
--- a/app/src/androidTest/java/com/readrops/app/LocalRSSRepositoryTest.kt
+++ b/app/src/androidTest/java/com/readrops/app/repositories/LocalRSSRepositoryTest.kt
@@ -1,60 +1,43 @@
-package com.readrops.app
+package com.readrops.app.repositories
 
-import android.content.Context
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import com.readrops.api.apiModule
 import com.readrops.api.utils.ApiUtils
-import com.readrops.api.utils.AuthInterceptor
-import com.readrops.app.repositories.LocalRSSRepository
+import com.readrops.app.testutil.ReadropsTestRule
+import com.readrops.app.testutil.TestUtils
 import com.readrops.db.Database
 import com.readrops.db.entities.Feed
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.runTest
-import okhttp3.OkHttpClient
 import okhttp3.mockwebserver.MockResponse
 import okhttp3.mockwebserver.MockWebServer
 import okio.Buffer
+import org.junit.After
 import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
-import org.koin.dsl.module
+import org.koin.core.parameter.parametersOf
 import org.koin.test.KoinTest
-import org.koin.test.KoinTestRule
 import org.koin.test.get
+import org.koin.test.inject
 import java.net.HttpURLConnection
-import java.util.concurrent.TimeUnit
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 
-
 class LocalRSSRepositoryTest : KoinTest {
 
-    private val mockServer: MockWebServer = MockWebServer()
-    private val account = Account(accountType = AccountType.LOCAL)
-    private lateinit var database: Database
+    private val mockServer = MockWebServer()
+    private val database: Database by inject()
+
+    private val account = Account(type = AccountType.LOCAL)
     private lateinit var repository: LocalRSSRepository
     private lateinit var feeds: List
 
+    @get:Rule
+    val koinTest = ReadropsTestRule()
+
     @Before
     fun before() = runTest {
-        val context = ApplicationProvider.getApplicationContext()
-        database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
-
-        KoinTestRule.create {
-            modules(apiModule, module {
-                single { database }
-                single {
-                    OkHttpClient.Builder()
-                        .callTimeout(1, TimeUnit.MINUTES)
-                        .readTimeout(1, TimeUnit.HOURS)
-                        .addInterceptor(get())
-                        .build()
-                }
-            })
-        }
-
         mockServer.start()
         val url = mockServer.url("/rss")
 
@@ -71,7 +54,12 @@ class LocalRSSRepositoryTest : KoinTest {
             feeds.first().id = first().toInt()
         }
 
-        repository = LocalRSSRepository(get(), database, account)
+        repository = get { parametersOf(account) } as LocalRSSRepository
+    }
+
+    @After
+    fun after() {
+        mockServer.shutdown()
     }
 
     @Test
diff --git a/app/src/androidTest/java/com/readrops/app/sync/SyncAnalyzerTest.kt b/app/src/androidTest/java/com/readrops/app/sync/SyncAnalyzerTest.kt
new file mode 100644
index 000000000..4c3bff81e
--- /dev/null
+++ b/app/src/androidTest/java/com/readrops/app/sync/SyncAnalyzerTest.kt
@@ -0,0 +1,258 @@
+package com.readrops.app.sync
+
+import android.content.Context
+import android.util.Log
+import com.readrops.app.R
+import com.readrops.app.repositories.SyncResult
+import com.readrops.app.testutil.ReadropsTestRule
+import com.readrops.db.Database
+import com.readrops.db.entities.Feed
+import com.readrops.db.entities.Item
+import com.readrops.db.entities.account.Account
+import com.readrops.db.entities.account.AccountType
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.koin.test.KoinTest
+import org.koin.test.get
+import org.koin.test.inject
+import java.time.LocalDateTime
+import kotlin.test.assertNotNull
+
+class SyncAnalyzerTest : KoinTest {
+
+    private val database: Database by inject()
+    private val syncAnalyzer: SyncAnalyzer by inject()
+
+    @get:Rule
+    val testRule = ReadropsTestRule()
+
+    private val account1 = Account(
+        name = "test account 1",
+        type = AccountType.FRESHRSS,
+        isNotificationsEnabled = true
+    )
+
+    private val account2 = Account(
+        name = "test account 2",
+        type = AccountType.NEXTCLOUD_NEWS,
+        isNotificationsEnabled = false
+    )
+
+    private val account3 = Account(
+        name = "test account 3",
+        type = AccountType.LOCAL,
+        isNotificationsEnabled = true
+    )
+
+    @Before
+    fun before() = runTest {
+        println("BeforeAll called")
+        val accounts = listOf(
+            account1,
+            account2,
+            account3
+        )
+
+        database.accountDao().insert(accounts)
+            .zip(accounts)
+            .forEach { (id, account) -> account.id = id.toInt() }
+
+        for ((index, account) in accounts.withIndex()) {
+            val feed = Feed(
+                name = "Feed $index",
+                iconUrl = "https://url.com/icon.jpg",
+                accountId = account.id,
+                isNotificationEnabled = index % 2 == 0,
+            )
+
+            database.feedDao().insert(feed)
+        }
+    }
+
+    @Test
+    fun oneElementEveryWhereTest() = runTest {
+        val item = Item(
+            title = "caseOneElementEveryWhere",
+            feedId = 1,
+            remoteId = "item 1",
+            pubDate = LocalDateTime.now()
+        )
+
+        val syncResult = SyncResult(items = listOf(item))
+        val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
+
+        assertNotNull(content)
+        assertEquals("caseOneElementEveryWhere", content.text)
+        assertEquals("Feed 0", content.title)
+        assertTrue(content.largeIcon != null)
+        assertTrue(content.accountId > 0)
+    }
+
+    @Test
+    fun twoItemsOneFeedTest() = runTest {
+        val item = Item(title = "caseTwoItemsOneFeed", feedId = 1)
+        val syncResult = SyncResult(items = listOf(item, item, item))
+
+        syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult)).let { content ->
+            assertNotNull(content)
+
+            assertEquals(get().getString(R.string.new_items, 3), content.text)
+            assertEquals("Feed 0", content.title)
+            assertTrue(content.largeIcon != null)
+            assertTrue(content.accountId > 0)
+        }
+    }
+
+    @Test
+    fun multipleFeedsTest() = runTest {
+        val item = Item(feedId = 1)
+        val item2 = Item(feedId = 3)
+
+        val syncResult = SyncResult(items = listOf(item, item2))
+        val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
+
+        assertNotNull(content)
+        assertEquals(get().getString(R.string.new_items, 2), content.text)
+        assertEquals(account1.name, content.title)
+        assertTrue(content.largeIcon != null)
+        assertTrue(content.accountId > 0)
+    }
+
+    @Test
+    fun multipleAccountsTest() = runTest {
+        val item = Item(feedId = 1)
+        val item2 = Item(feedId = 3)
+
+        val syncResult = SyncResult(items = listOf(item, item2))
+        val syncResult2 = SyncResult(items = listOf(item, item2))
+        val syncResults = mapOf(account1 to syncResult, account3 to syncResult2)
+
+        val content = syncAnalyzer.getNotificationContent(syncResults)
+
+        assertNotNull(content)
+        assertEquals(get().getString(R.string.new_items, 4), content.title)
+    }
+
+    @Test
+    fun accountNotificationsDisabledTest() = runTest {
+        val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 1)
+        val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 1)
+
+        val syncResult = SyncResult(items = listOf(item1, item2))
+        assertNull(syncAnalyzer.getNotificationContent(mapOf(account2 to syncResult)))
+    }
+
+    @Test
+    fun feedNotificationsDisabledTest() = runTest {
+        val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 2)
+        val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 2)
+
+        Log.d("SyncAnalyzerTest", "$account1")
+
+        val syncResult = SyncResult(items = listOf(item1, item2))
+        val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
+        assertNull(content)
+    }
+
+    @Test
+    fun twoAccountsWithOneAccountNotificationsEnabledTest() = runTest {
+        val item1 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
+            feedId = 1,
+            remoteId = "remoteId 1",
+            pubDate = LocalDateTime.now()
+        )
+
+        val item2 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
+            feedId = 3
+        )
+
+        val item3 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
+            feedId = 3
+        )
+
+        val syncResult1 = SyncResult(items = listOf(item1))
+        val syncResult2 = SyncResult(items = listOf(item2, item3))
+
+        val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
+
+        val content = syncAnalyzer.getNotificationContent(syncResults)
+
+        assertNotNull(content)
+        assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
+        assertEquals("Feed 0", content.title)
+        assertTrue(content.largeIcon != null)
+        assertTrue(content.item != null)
+    }
+
+    @Test
+    fun twoAccountsWithOneFeedNotificationEnabledTest() = runTest {
+        val item1 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
+            feedId = 1,
+            remoteId = "remoteId 1",
+            pubDate = LocalDateTime.now()
+        )
+
+        val item2 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
+            feedId = 2
+        )
+
+        val item3 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
+            feedId = 2
+        )
+
+        val syncResult1 = SyncResult(items = listOf(item1))
+        val syncResult2 = SyncResult(items = listOf(item2, item3))
+
+        val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
+
+        val content = syncAnalyzer.getNotificationContent(syncResults)
+
+        assertNotNull(content)
+        assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
+        assertEquals("Feed 0", content.title)
+        assertTrue(content.largeIcon != null)
+        assertTrue(content.item != null)
+    }
+
+
+    @Test
+    fun oneAccountTwoFeedsWithOneFeedNotificationEnabledTest() = runTest {
+        val item1 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled",
+            feedId = 1,
+            remoteId = "remoteId 1",
+            pubDate = LocalDateTime.now()
+        )
+
+        val item2 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
+            feedId = 2
+        )
+
+        val item3 = Item(
+            title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
+            feedId = 2
+        )
+
+        val syncResult = SyncResult(items = listOf(item1, item2, item3))
+        val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
+
+        assertNotNull(content)
+        assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
+        assertEquals("Feed 0", content.title)
+        assertTrue(content.largeIcon != null)
+        assertTrue(content.item != null)
+        assertTrue(content.accountId > 0)
+    }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/testutil/ReadropsTestRule.kt b/app/src/androidTest/java/com/readrops/app/testutil/ReadropsTestRule.kt
new file mode 100644
index 000000000..81e60b44b
--- /dev/null
+++ b/app/src/androidTest/java/com/readrops/app/testutil/ReadropsTestRule.kt
@@ -0,0 +1,60 @@
+package com.readrops.app.testutil
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import com.readrops.api.apiModule
+import com.readrops.app.appModule
+import com.readrops.db.Database
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.Koin
+import org.koin.core.annotation.KoinInternalApi
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.core.logger.Level
+import org.koin.dsl.module
+import org.koin.mp.KoinPlatformTools
+
+@OptIn(KoinInternalApi::class)
+class ReadropsTestRule : TestWatcher() {
+
+    private var _koin: Koin? = null
+    val koin: Koin
+        get() = _koin ?: error("No Koin application found")
+
+    override fun starting(description: Description?) {
+        closeExistingInstance()
+        _koin = startKoin {
+            androidLogger(Level.INFO)
+            androidContext(ApplicationProvider.getApplicationContext())
+
+            modules(
+                module {
+                    single {
+                        Room.inMemoryDatabaseBuilder(get(), Database::class.java)
+                            .build()
+                    }
+                },
+                apiModule, appModule
+            )
+        }.koin
+
+        koin.logger.info("Koin Rule - starting")
+    }
+
+    private fun closeExistingInstance() {
+        KoinPlatformTools.defaultContext().getOrNull()?.let { koin ->
+            koin.logger.info("Koin Rule - closing existing instance")
+            koin.close()
+        }
+    }
+
+    override fun finished(description: Description?) {
+        koin.logger.info("Koin Rule - finished")
+        stopKoin()
+        _koin = null
+    }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/readrops/app/TestUtils.kt b/app/src/androidTest/java/com/readrops/app/testutil/TestUtils.kt
similarity index 82%
rename from app/src/androidTest/java/com/readrops/app/TestUtils.kt
rename to app/src/androidTest/java/com/readrops/app/testutil/TestUtils.kt
index 5cf190db9..efe76f384 100644
--- a/app/src/androidTest/java/com/readrops/app/TestUtils.kt
+++ b/app/src/androidTest/java/com/readrops/app/testutil/TestUtils.kt
@@ -1,4 +1,4 @@
-package com.readrops.app
+package com.readrops.app.testutil
 
 import java.io.InputStream
 
diff --git a/app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt b/app/src/androidTest/java/com/readrops/app/util/FeedColorsTest.kt
similarity index 62%
rename from app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt
rename to app/src/androidTest/java/com/readrops/app/util/FeedColorsTest.kt
index fdfb41bd8..5fc780139 100644
--- a/app/src/androidTest/java/com/readrops/app/FeedColorsTest.kt
+++ b/app/src/androidTest/java/com/readrops/app/util/FeedColorsTest.kt
@@ -1,36 +1,29 @@
-package com.readrops.app
+package com.readrops.app.util
 
-import android.content.Context
-import androidx.test.core.app.ApplicationProvider
-import com.readrops.api.apiModule
 import com.readrops.api.utils.ApiUtils
-import com.readrops.app.util.FeedColors
-import kotlinx.coroutines.runBlocking
+import com.readrops.app.testutil.ReadropsTestRule
+import com.readrops.app.testutil.TestUtils
+import kotlinx.coroutines.test.runTest
 import okhttp3.mockwebserver.MockResponse
 import okhttp3.mockwebserver.MockWebServer
 import okio.Buffer
 import org.junit.After
 import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
-import org.koin.dsl.module
-import org.koin.test.KoinTestRule
+import org.koin.test.KoinTest
 import java.net.HttpURLConnection
 import kotlin.test.assertTrue
 
-class FeedColorsTest {
+class FeedColorsTest : KoinTest {
 
     private val mockServer = MockWebServer()
 
+    @get:Rule
+    val testRule = ReadropsTestRule()
+
     @Before
     fun before() {
-        val context = ApplicationProvider.getApplicationContext()
-
-        KoinTestRule.create {
-            modules(apiModule, module {
-                single { context }
-            })
-        }
-
         mockServer.start()
     }
 
@@ -40,7 +33,7 @@ class FeedColorsTest {
     }
 
     @Test
-    fun getFeedColorTest() = runBlocking {
+    fun getFeedColorTest() = runTest {
         val stream = TestUtils.loadResource("favicon.ico")
 
         mockServer.enqueue(
diff --git a/app/src/main/java/com/readrops/app/AppModule.kt b/app/src/main/java/com/readrops/app/AppModule.kt
index 1cd2a362b..00c6d45e8 100644
--- a/app/src/main/java/com/readrops/app/AppModule.kt
+++ b/app/src/main/java/com/readrops/app/AppModule.kt
@@ -15,20 +15,26 @@ import com.readrops.app.account.credentials.AccountCredentialsScreenMode
 import com.readrops.app.account.credentials.AccountCredentialsScreenModel
 import com.readrops.app.account.selection.AccountSelectionScreenModel
 import com.readrops.app.feeds.FeedScreenModel
+import com.readrops.app.feeds.color.FeedColorScreenModel
+import com.readrops.app.feeds.newfeed.NewFeedScreenModel
 import com.readrops.app.item.ItemScreenModel
 import com.readrops.app.more.preferences.PreferencesScreenModel
 import com.readrops.app.notifications.NotificationsScreenModel
 import com.readrops.app.repositories.BaseRepository
 import com.readrops.app.repositories.FeverRepository
-import com.readrops.app.repositories.FreshRSSRepository
+import com.readrops.app.repositories.GReaderRepository
 import com.readrops.app.repositories.GetFoldersWithFeeds
 import com.readrops.app.repositories.LocalRSSRepository
 import com.readrops.app.repositories.NextcloudNewsRepository
+import com.readrops.app.sync.SyncAnalyzer
+import com.readrops.app.sync.Synchronizer
 import com.readrops.app.timelime.TimelineScreenModel
 import com.readrops.app.util.DataStorePreferences
 import com.readrops.app.util.Preferences
+import com.readrops.db.entities.Feed
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
+import com.readrops.db.filters.QueryFilters
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
@@ -38,30 +44,42 @@ import org.koin.dsl.module
 
 val appModule = module {
 
-    factory { TimelineScreenModel(get(), get(), get()) }
+    factory { TimelineScreenModel(get(), get(), get(), androidContext()) }
 
-    factory { FeedScreenModel(get(), get(), get(), androidContext()) }
+    factory { FeedScreenModel(get(), get(), androidContext()) }
 
-    factory { AccountSelectionScreenModel(get()) }
+    factory { (url: String?) -> NewFeedScreenModel(get(), get(), androidContext(), url) }
 
-    factory { AccountScreenModel(get()) }
+    factory { AccountSelectionScreenModel(get(), get()) }
 
-    factory { (itemId: Int) -> ItemScreenModel(get(), itemId, get()) }
+    factory { AccountScreenModel(get(), androidContext()) }
+
+    factory { (itemId: Int, itemIndex: Int, queryFilters: QueryFilters) ->
+        ItemScreenModel(
+            itemId = itemId,
+            itemIndex = itemIndex,
+            queryFilters = queryFilters,
+            database = get(),
+            preferences = get()
+        )
+    }
 
     factory { (accountType: Account, mode: AccountCredentialsScreenMode) ->
-        AccountCredentialsScreenModel(accountType, mode, get())
+        AccountCredentialsScreenModel(accountType, mode, get(), context = androidContext())
     }
 
     factory { (account: Account) -> NotificationsScreenModel(account, get(), get(), get()) }
 
-    factory { PreferencesScreenModel(get()) }
+    factory { PreferencesScreenModel(get(), get(), get()) }
+
+    factory { (feed: Feed) -> FeedColorScreenModel(feed, get()) }
 
     single { GetFoldersWithFeeds(get()) }
 
     factory { (account: Account) ->
-        when (account.accountType) {
+        when (account.type) {
             AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
-            AccountType.FRESHRSS -> FreshRSSRepository(
+            AccountType.FRESHRSS, AccountType.GREADER -> GReaderRepository(
                 database = get(),
                 account = account,
                 dataSource = get(parameters = { parametersOf(Credentials.toCredentials(account)) })
@@ -113,4 +131,8 @@ val appModule = module {
     single { Preferences(get()) }
 
     single { NotificationManagerCompat.from(get()) }
+
+    single { Synchronizer(get(), get(), get(), get()) }
+
+    single { SyncAnalyzer(get(), get()) }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/MainActivity.kt b/app/src/main/java/com/readrops/app/MainActivity.kt
index 775d7bb2d..475d6cef2 100644
--- a/app/src/main/java/com/readrops/app/MainActivity.kt
+++ b/app/src/main/java/com/readrops/app/MainActivity.kt
@@ -8,12 +8,20 @@ import androidx.activity.ComponentActivity
 import androidx.activity.SystemBarStyle
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.BottomAppBarDefaults
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBarDefaults
-import androidx.compose.material3.surfaceColorAtElevation
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.toArgb
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.lifecycleScope
@@ -24,6 +32,7 @@ import cafe.adriel.voyager.transitions.SlideTransition
 import com.readrops.app.account.selection.AccountSelectionScreen
 import com.readrops.app.account.selection.AccountSelectionScreenModel
 import com.readrops.app.home.HomeScreen
+import com.readrops.app.repositories.BaseRepository
 import com.readrops.app.sync.SyncWorker
 import com.readrops.app.timelime.TimelineTab
 import com.readrops.app.util.Preferences
@@ -34,18 +43,24 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.KoinAndroidContext
-import org.koin.core.annotation.KoinExperimentalAPI
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
+import org.koin.core.parameter.parametersOf
 
 class MainActivity : ComponentActivity(), KoinComponent {
 
-    @OptIn(KoinExperimentalAPI::class, ExperimentalVoyagerApi::class)
+    var ready = false
+
+    @OptIn(ExperimentalVoyagerApi::class)
     override fun onCreate(savedInstanceState: Bundle?) {
-        installSplashScreen()
+        val splashScreen = installSplashScreen()
         super.onCreate(savedInstanceState)
 
+        // Disable waiting for timeline tab list to be populated before removing splash screen
+        //splashScreen.setKeepOnScreenCondition { !ready }
+
         val screenModel = get()
         val accountExists = screenModel.accountExists()
 
@@ -55,8 +70,8 @@ class MainActivity : ComponentActivity(), KoinComponent {
         val initialUseDarkTheme = runBlocking {
             useDarkTheme(preferences.theme.flow.first(), darkFlag)
         }
-        val initialColourScheme = runBlocking {
-            preferences.themeColourScheme.flow.first()
+        val initialColorScheme = runBlocking {
+            preferences.themeColorScheme.flow.first()
         }
 
         setContent {
@@ -64,22 +79,22 @@ class MainActivity : ComponentActivity(), KoinComponent {
                 val useDarkTheme by preferences.theme.flow
                     .map { mode -> useDarkTheme(mode, darkFlag) }
                     .collectAsState(initial = initialUseDarkTheme)
-                val themeColourScheme by preferences.themeColourScheme.flow
-                    .collectAsState(initial = initialColourScheme)
+                val themeColorScheme by preferences.themeColorScheme.flow
+                    .collectAsState(initial = initialColorScheme)
 
                 ReadropsTheme(
                     useDarkTheme = useDarkTheme,
-                    themeColourScheme = themeColourScheme
+                    themeColorScheme = themeColorScheme
                 ) {
-                    val navigationBarElevation = NavigationBarDefaults.Elevation
-
                     enableEdgeToEdge(
-                        statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
+                        statusBarStyle = SystemBarStyle.auto(
+                            lightScrim = Color.TRANSPARENT,
+                            darkScrim = Color.TRANSPARENT,
+                            detectDarkMode = { useDarkTheme }
+                        ),
                         navigationBarStyle = SystemBarStyle.light(
-                            MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
-                                .toArgb(),
-                            MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
-                                .toArgb()
+                            scrim = BottomAppBarDefaults.containerColor.toArgb(),
+                            darkScrim = BottomAppBarDefaults.containerColor.toArgb()
                         )
                     )
 
@@ -95,10 +110,22 @@ class MainActivity : ComponentActivity(), KoinComponent {
                             handleIntent(intent)
                         }
 
-                        SlideTransition(
-                            navigator = navigator,
-                            disposeScreenAfterTransitionEnd = true
-                        )
+                        Box(
+                            modifier = Modifier
+                                .background(MaterialTheme.colorScheme.background)
+                                // custom safe drawing to be able to draw behind the status bar
+                                .windowInsetsPadding(
+                                    WindowInsets.safeDrawing.only(
+                                        WindowInsetsSides.Start + WindowInsetsSides.End
+                                    )
+                                )
+                        ) {
+                            SlideTransition(
+                                navigator = navigator,
+                                modifier = Modifier.imePadding(),
+                                disposeScreenAfterTransitionEnd = true
+                            )
+                        }
                     }
                 }
             }
@@ -108,23 +135,31 @@ class MainActivity : ComponentActivity(), KoinComponent {
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
 
-        lifecycleScope.launch(Dispatchers.IO) {
+        lifecycleScope.launch {
             handleIntent(intent)
         }
     }
 
-    private suspend fun handleIntent(intent: Intent) {
+    private suspend fun handleIntent(intent: Intent) = withContext(Dispatchers.IO) {
         when {
             intent.hasExtra(SyncWorker.ACCOUNT_ID_KEY) -> {
                 val accountId = intent.getIntExtra(SyncWorker.ACCOUNT_ID_KEY, -1)
-                get().accountDao()
-                    .updateCurrentAccount(accountId)
+                val database = get().also {
+                    it.accountDao()
+                        .updateCurrentAccount(accountId)
+                }
 
                 HomeScreen.openTab(TimelineTab)
 
                 if (intent.hasExtra(SyncWorker.ITEM_ID_KEY)) {
                     val itemId = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
-                    HomeScreen.openItemScreen(itemId)
+                    val account = database.accountDao().select(accountId)
+                    val item = database.itemDao().select(itemId)
+                        .apply { isRead = true }
+
+                    get(parameters = { parametersOf(account) })
+                        .setItemReadState(item)
+                    HomeScreen.openItem(itemId)
                 }
             }
 
diff --git a/app/src/main/java/com/readrops/app/ReadropsApp.kt b/app/src/main/java/com/readrops/app/ReadropsApp.kt
index a8003743a..54706eebd 100644
--- a/app/src/main/java/com/readrops/app/ReadropsApp.kt
+++ b/app/src/main/java/com/readrops/app/ReadropsApp.kt
@@ -6,13 +6,21 @@ import android.app.NotificationManager
 import android.content.Intent
 import android.os.Build
 import androidx.core.app.NotificationManagerCompat
-import coil.ImageLoader
-import coil.ImageLoaderFactory
-import coil.disk.DiskCache
+import androidx.preference.PreferenceManager
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import coil3.disk.DiskCache
+import coil3.disk.directory
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import coil3.request.crossfade
 import com.readrops.api.apiModule
 import com.readrops.app.util.CrashActivity
 import com.readrops.app.util.FeverFaviconFetcher
+import com.readrops.app.util.Migrations
 import com.readrops.db.dbModule
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
 import org.koin.android.ext.android.get
 import org.koin.android.ext.koin.androidContext
 import org.koin.android.ext.koin.androidLogger
@@ -21,20 +29,22 @@ import org.koin.core.context.startKoin
 import org.koin.core.logger.Level
 import kotlin.system.exitProcess
 
-open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
+open class ReadropsApp : Application(), KoinComponent, SingletonImageLoader.Factory {
 
     override fun onCreate() {
         super.onCreate()
 
-        Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
-            val intent = Intent(this, CrashActivity::class.java).apply {
-                putExtra(CrashActivity.THROWABLE_KEY, throwable)
-                addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
-                addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
-            }
+        if (!BuildConfig.DEBUG) {
+            Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
+                val intent = Intent(this, CrashActivity::class.java).apply {
+                    putExtra(CrashActivity.THROWABLE_KEY, throwable)
+                    addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
+                    addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+                }
 
-            startActivity(intent)
-            exitProcess(0)
+                startActivity(intent)
+                exitProcess(0)
+            }
         }
 
         startKoin {
@@ -45,12 +55,30 @@ open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
         }
 
         createNotificationChannels()
+
+        runBlocking {
+            Migrations.upgrade(
+                appPreferences = get(),
+                encryptedPreferences = get(),
+                oldPreferences = PreferenceManager.getDefaultSharedPreferences(this@ReadropsApp),
+                database = get(),
+            )
+        }
     }
 
-    override fun newImageLoader(): ImageLoader {
+    override fun newImageLoader(context: PlatformContext): ImageLoader {
         return ImageLoader.Builder(this)
-            .okHttpClient { get() }
-            .components { add(FeverFaviconFetcher.Factory(get())) }
+            .components {
+                add(OkHttpNetworkFetcherFactory(callFactory = {
+                    val client = get()
+                    // custom shared Okhttp instance to avoid mixing
+                    // authentication headers with basic image calls
+                    client.newBuilder()
+                        .build()
+                }))
+
+                add(FeverFaviconFetcher.Factory(get()))
+            }
             .diskCache {
                 DiskCache.Builder()
                     .directory(this.cacheDir.resolve("image_cache"))
diff --git a/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt b/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt
index 1e998f8b0..0768517ee 100644
--- a/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/account/AccountScreenModel.kt
@@ -6,7 +6,8 @@ import androidx.compose.runtime.Stable
 import androidx.core.net.toFile
 import cafe.adriel.voyager.core.model.screenModelScope
 import com.readrops.api.opml.OPMLParser
-import com.readrops.app.base.TabScreenModel
+import com.readrops.app.R
+import com.readrops.app.home.TabScreenModel
 import com.readrops.app.repositories.ErrorResult
 import com.readrops.app.repositories.GetFoldersWithFeeds
 import com.readrops.app.util.components.TextFieldError
@@ -29,8 +30,9 @@ import org.koin.core.component.get
 
 class AccountScreenModel(
     private val database: Database,
+    context: Context,
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
-) : TabScreenModel(database) {
+) : TabScreenModel(database, context) {
 
     private val _closeHome = MutableStateFlow(false)
     val closeHome = _closeHome.asStateFlow()
@@ -93,7 +95,11 @@ class AccountScreenModel(
         screenModelScope.launch(dispatcher) {
             val stream = context.contentResolver.openOutputStream(uri)
             if (stream == null) {
-                _accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
+                _accountState.update {
+                    it.copy(
+                        error = accountError?.genericMessage(NoSuchFileException(uri.toFile()))
+                    )
+                }
                 return@launch
             }
 
@@ -122,13 +128,22 @@ class AccountScreenModel(
             try {
                 val stream = context.contentResolver.openInputStream(uri)
                 if (stream == null) {
-                    _accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
+                    _accountState.update {
+                        it.copy(
+                            error = accountError?.genericMessage(NoSuchFileException(uri.toFile()))
+                        )
+                    }
                     return@launch
                 }
 
                 foldersAndFeeds = OPMLParser.read(stream)
             } catch (e: Exception) {
-                _accountState.update { it.copy(error = e) }
+                _accountState.update { it.copy(error = accountError?.genericMessage(e)) }
+                return@launch
+            }
+
+            if (foldersAndFeeds.isEmpty()) {
+                _accountState.update { it.copy(error = context.getString(R.string.empty_file)) }
                 return@launch
             }
 
@@ -178,8 +193,8 @@ class AccountScreenModel(
     fun createLocalAccount() {
         val context = get()
         val account = Account(
-            accountName = context.getString(AccountType.LOCAL.typeName),
-            accountType = AccountType.LOCAL,
+            name = context.getString(AccountType.LOCAL.nameRes),
+            type = AccountType.LOCAL,
             isCurrentAccount = true
         )
 
@@ -212,10 +227,10 @@ class AccountScreenModel(
 
 @Stable
 data class AccountState(
-    val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
+    val account: Account = Account(name = "account", type = AccountType.LOCAL),
     val dialog: DialogState? = null,
     val synchronizationErrors: ErrorResult? = null,
-    val error: Exception? = null,
+    val error: String? = null,
     val opmlExportSuccess: Boolean = false,
     val opmlExportUri: Uri? = null,
     val accounts: List = emptyList(),
@@ -225,11 +240,12 @@ data class AccountState(
 sealed interface DialogState {
     data object DeleteAccount : DialogState
     data object NewAccount : DialogState
+    data class AccountWarning(val type: AccountType) : DialogState
     data class OPMLImport(val currentFeed: String?, val feedCount: Int, val feedMax: Int) :
         DialogState
 
     data class ErrorList(val errorResult: ErrorResult) : DialogState
-    data class Error(val exception: Exception) : DialogState
+    data class Error(val error: String) : DialogState
 
     data object OPMLChoice : DialogState
 
diff --git a/app/src/main/java/com/readrops/app/account/AccountTab.kt b/app/src/main/java/com/readrops/app/account/AccountTab.kt
index 482e2ac5b..a38acb7b7 100644
--- a/app/src/main/java/com/readrops/app/account/AccountTab.kt
+++ b/app/src/main/java/com/readrops/app/account/AccountTab.kt
@@ -1,5 +1,6 @@
 package com.readrops.app.account
 
+import android.content.Context
 import android.content.Intent
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
@@ -39,8 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.koin.koinScreenModel
 import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
 import cafe.adriel.voyager.navigator.currentOrThrow
 import cafe.adriel.voyager.navigator.tab.Tab
 import cafe.adriel.voyager.navigator.tab.TabOptions
@@ -48,12 +50,16 @@ import com.readrops.api.utils.ApiUtils
 import com.readrops.app.R
 import com.readrops.app.account.credentials.AccountCredentialsScreen
 import com.readrops.app.account.credentials.AccountCredentialsScreenMode
-import com.readrops.app.account.selection.AccountSelectionDialog
+import com.readrops.app.account.dialog.AccountSelectionDialog
+import com.readrops.app.account.dialog.AccountWarningDialog
+import com.readrops.app.account.dialog.OPML
+import com.readrops.app.account.dialog.OPMLChoiceDialog
+import com.readrops.app.account.dialog.OPMLImportProgressDialog
 import com.readrops.app.account.selection.AccountSelectionScreen
 import com.readrops.app.account.selection.adaptiveIconPainterResource
 import com.readrops.app.notifications.NotificationsScreen
 import com.readrops.app.repositories.ErrorResult
-import com.readrops.app.timelime.ErrorListDialog
+import com.readrops.app.timelime.dialog.ErrorListDialog
 import com.readrops.app.util.components.SelectableIconText
 import com.readrops.app.util.components.SelectableImageText
 import com.readrops.app.util.components.ThreeDotsMenu
@@ -64,6 +70,7 @@ import com.readrops.app.util.theme.LargeSpacer
 import com.readrops.app.util.theme.MediumSpacer
 import com.readrops.app.util.theme.VeryShortSpacer
 import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.account.ACCOUNT_APIS
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
 
@@ -81,7 +88,7 @@ object AccountTab : Tab {
     override fun Content() {
         val navigator = LocalNavigator.currentOrThrow
         val context = LocalContext.current
-        val screenModel = getScreenModel()
+        val screenModel = koinScreenModel()
 
         val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
         val state by screenModel.accountState.collectAsStateWithLifecycle()
@@ -134,7 +141,7 @@ object AccountTab : Tab {
         LaunchedEffect(state.opmlExportSuccess) {
             if (state.opmlExportSuccess) {
                 val action = snackbarHostState.showSnackbar(
-                    message = context.getString(R.string.opml_export_success), 
+                    message = context.getString(R.string.opml_export_success),
                     actionLabel = context.resources.getString(R.string.open)
                 )
 
@@ -192,7 +199,7 @@ object AccountTab : Tab {
                         modifier = Modifier.weight(1f)
                     ) {
                         Image(
-                            painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes),
+                            painter = adaptiveIconPainterResource(id = state.account.type!!.iconRes),
                             contentDescription = null,
                             modifier = Modifier.size(48.dp)
                         )
@@ -201,7 +208,7 @@ object AccountTab : Tab {
 
                         Column {
                             Text(
-                                text = state.account.accountName!!,
+                                text = state.account.name!!,
                                 style = MaterialTheme.typography.titleLarge,
                                 maxLines = 1,
                                 overflow = TextOverflow.Ellipsis
@@ -224,7 +231,7 @@ object AccountTab : Tab {
                         ThreeDotsMenu(
                             items = mapOf(1 to stringResource(id = R.string.rename_account)),
                             onItemClick = {
-                                screenModel.openDialog(DialogState.RenameAccount(state.account.accountName!!))
+                                screenModel.openDialog(DialogState.RenameAccount(state.account.name!!))
                             },
                         )
                     }
@@ -303,8 +310,8 @@ object AccountTab : Tab {
 
                     for (account in state.accounts) {
                         SelectableImageText(
-                            image = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
-                            text = account.accountName!!,
+                            image = adaptiveIconPainterResource(id = account.type!!.iconRes),
+                            text = account.name!!,
                             style = MaterialTheme.typography.titleMedium,
                             padding = MaterialTheme.spacing.mediumSpacing,
                             spacing = MaterialTheme.spacing.mediumSpacing,
@@ -357,22 +364,35 @@ object AccountTab : Tab {
                         if (accountType == AccountType.LOCAL) {
                             screenModel.createLocalAccount()
                         } else {
-                            val account = Account(
-                                accountType = accountType,
-                                accountName = context.resources.getString(accountType.typeName)
-                            )
-                            navigator.push(
-                                AccountCredentialsScreen(
-                                    account,
-                                    AccountCredentialsScreenMode.NEW_CREDENTIALS
+                            if (ACCOUNT_APIS.any { it == accountType }) {
+                                screenModel.openDialog(DialogState.AccountWarning(accountType))
+                            } else {
+                                pushAccount(
+                                    type = accountType,
+                                    context = context,
+                                    navigator = navigator
                                 )
-                            )
+                            }
                         }
-
                     }
                 )
             }
 
+            is DialogState.AccountWarning -> {
+                AccountWarningDialog(
+                    type = dialog.type,
+                    onConfirm = {
+                        screenModel.closeDialog()
+                        pushAccount(
+                            type = dialog.type,
+                            context = context,
+                            navigator = navigator
+                        )
+                    },
+                    onDismiss = { screenModel.closeDialog(dialog) }
+                )
+            }
+
             is DialogState.OPMLImport -> {
                 OPMLImportProgressDialog(
                     currentFeed = dialog.currentFeed,
@@ -390,7 +410,7 @@ object AccountTab : Tab {
 
             is DialogState.Error -> {
                 ErrorDialog(
-                    exception = dialog.exception,
+                    error = dialog.error,
                     onDismiss = { screenModel.closeDialog(dialog) }
                 )
             }
@@ -425,4 +445,18 @@ object AccountTab : Tab {
             else -> {}
         }
     }
+
+    private fun pushAccount(type: AccountType, context: Context, navigator: Navigator) {
+        val account = Account(
+            type = type,
+            name = context.resources.getString(type.nameRes)
+        )
+
+        navigator.push(
+            AccountCredentialsScreen(
+                account = account,
+                mode = AccountCredentialsScreenMode.NEW_CREDENTIALS
+            )
+        )
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt
index a7ebef3aa..5b46c39cd 100644
--- a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt
+++ b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
@@ -27,27 +28,40 @@ import androidx.compose.material3.TopAppBar
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofill
+import androidx.compose.ui.platform.LocalAutofillTree
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.koin.koinScreenModel
 import cafe.adriel.voyager.navigator.LocalNavigator
 import cafe.adriel.voyager.navigator.currentOrThrow
 import com.readrops.app.R
 import com.readrops.app.account.selection.adaptiveIconPainterResource
 import com.readrops.app.home.HomeScreen
-import com.readrops.app.util.ErrorMessage
+import com.readrops.app.util.accounterror.AccountError
 import com.readrops.app.util.components.AndroidScreen
+import com.readrops.app.util.theme.LargeSpacer
 import com.readrops.app.util.theme.MediumSpacer
 import com.readrops.app.util.theme.ShortSpacer
 import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.account.ACCOUNT_APIS
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
 import org.koin.core.parameter.parametersOf
@@ -57,17 +71,43 @@ enum class AccountCredentialsScreenMode {
     EDIT_CREDENTIALS
 }
 
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun Modifier.autofill(
+    autofillTypes: List,
+    onFill: ((String) -> Unit),
+) = composed {
+    val autofill = LocalAutofill.current
+    val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
+    LocalAutofillTree.current += autofillNode
+
+    this.onGloballyPositioned {
+        autofillNode.boundingBox = it.boundsInWindow()
+    }.onFocusChanged { focusState ->
+        autofill?.run {
+            if (focusState.isFocused) {
+                requestAutofillForNode(autofillNode)
+            } else {
+                cancelAutofillForNode(autofillNode)
+            }
+        }
+    }
+}
+
 class AccountCredentialsScreen(
     private val account: Account,
     private val mode: AccountCredentialsScreenMode
 ) : AndroidScreen() {
 
-    @OptIn(ExperimentalMaterial3Api::class)
+    @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
     @Composable
     override fun Content() {
         val navigator = LocalNavigator.currentOrThrow
+        val keyboardController = LocalSoftwareKeyboardController.current
+        val accountError = AccountError.from(account, LocalContext.current)
+
         val screenModel =
-            getScreenModel(parameters = { parametersOf(account, mode) })
+            koinScreenModel(parameters = { parametersOf(account, mode) })
 
         val state by screenModel.state.collectAsStateWithLifecycle()
 
@@ -117,7 +157,7 @@ class AccountCredentialsScreen(
                         .verticalScroll(rememberScrollState())
                 ) {
                     Image(
-                        painter = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
+                        painter = adaptiveIconPainterResource(id = account.type!!.iconRes),
                         contentDescription = null,
                         modifier = Modifier.size(48.dp)
                     )
@@ -125,7 +165,7 @@ class AccountCredentialsScreen(
                     ShortSpacer()
 
                     Text(
-                        text = stringResource(id = account.accountType!!.typeName),
+                        text = stringResource(id = account.type!!.nameRes),
                         style = MaterialTheme.typography.headlineMedium
                     )
 
@@ -155,7 +195,7 @@ class AccountCredentialsScreen(
                                 state.urlError != null -> {
                                     Text(text = state.urlError!!.errorText())
                                 }
-                                account.accountType == AccountType.FEVER -> {
+                                ACCOUNT_APIS.any { it == account.type }  -> {
                                     Text(text = stringResource(R.string.provide_full_url))
                                 }
                                 else -> {
@@ -163,7 +203,10 @@ class AccountCredentialsScreen(
                                 }
                             }
                         },
-                        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+                        keyboardOptions = KeyboardOptions(
+                            keyboardType = KeyboardType.Uri,
+                            imeAction = ImeAction.Next
+                        ),
                         modifier = Modifier.fillMaxWidth()
                     )
 
@@ -177,7 +220,10 @@ class AccountCredentialsScreen(
                         isError = state.isLoginError,
                         supportingText = { Text(text = state.loginError?.errorText().orEmpty()) },
                         keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
-                        modifier = Modifier.fillMaxWidth()
+                        modifier = Modifier.autofill(
+                            listOf(AutofillType.Username),
+                            onFill = { screenModel.onEvent(Event.LoginEvent(it)) }
+                        ).fillMaxWidth()
                     )
 
                     ShortSpacer()
@@ -211,7 +257,7 @@ class AccountCredentialsScreen(
                                 state.passwordError != null -> {
                                     Text(text = state.passwordError!!.errorText())
                                 }
-                                account.accountType == AccountType.FRESHRSS -> {
+                                account.type == AccountType.FRESHRSS -> {
                                     Text(text = stringResource(id = R.string.password_helper))
                                 }
                             }
@@ -220,10 +266,19 @@ class AccountCredentialsScreen(
                             keyboardType = KeyboardType.Password,
                             imeAction = ImeAction.Done
                         ),
-                        modifier = Modifier.fillMaxWidth()
+                        keyboardActions = KeyboardActions(
+                            onDone = {
+                                keyboardController?.hide()
+                                screenModel.login()
+                            }
+                        ),
+                        modifier = Modifier.autofill(
+                            autofillTypes = listOf(AutofillType.Password),
+                            onFill = { screenModel.onEvent(Event.PasswordEvent(it)) }
+                        ).fillMaxWidth()
                     )
 
-                    ShortSpacer()
+                    LargeSpacer()
 
                     Button(
                         onClick = { screenModel.login() },
@@ -244,9 +299,10 @@ class AccountCredentialsScreen(
                         ShortSpacer()
 
                         Text(
-                            text = ErrorMessage.get(state.loginException!!, LocalContext.current),
+                            text = accountError.genericMessage(state.loginException!!),
                             style = MaterialTheme.typography.labelMedium,
-                            color = MaterialTheme.colorScheme.error
+                            color = MaterialTheme.colorScheme.error,
+                            textAlign = TextAlign.Center
                         )
                     }
                 }
diff --git a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt
index 1c79cb6f3..8a3d05090 100644
--- a/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/account/credentials/AccountCredentialsScreenModel.kt
@@ -1,13 +1,16 @@
 package com.readrops.app.account.credentials
 
+import android.content.Context
 import android.content.SharedPreferences
-import android.util.Patterns
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
+import com.readrops.app.R
 import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.util.Utils
 import com.readrops.app.util.components.TextFieldError
 import com.readrops.db.Database
 import com.readrops.db.entities.account.Account
+import com.readrops.db.entities.account.AccountType
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.update
@@ -16,25 +19,28 @@ import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
 import org.koin.core.parameter.parametersOf
 
+
 class AccountCredentialsScreenModel(
     private val account: Account,
     private val mode: AccountCredentialsScreenMode,
     private val database: Database,
-    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
-) : StateScreenModel(AccountCredentialsState()), KoinComponent {
-
+    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+    context: Context,
+) : StateScreenModel(
+    initAccountCredentialsState(account, context)
+), KoinComponent {
     init {
         if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) {
             mutableState.update {
                 it.copy(
-                    name = account.accountName!!,
+                    name = account.name!!,
                     url = account.url!!,
                     login = account.login!!,
                     password = account.password!!
                 )
             }
         } else {
-            mutableState.update { it.copy(name = account.accountName!!) }
+            mutableState.update { it.copy(name = account.name!!) }
         }
     }
 
@@ -52,30 +58,25 @@ class AccountCredentialsScreenModel(
     }
 
     fun login() {
-        if (validateFields()) {
-            mutableState.update { it.copy(isLoginOnGoing = true) }
-
-            with(state.value) {
-                val normalizedUrl = if (!url.contains("https://") && !url.contains("http://")) {
-                    "https://$url"
-                } else {
-                    url
-                }
+        screenModelScope.launch(dispatcher) {
+            if (validateFields()) {
+                mutableState.update { it.copy(isLoginOnGoing = true) }
+
+                with(state.value) {
+                    val normalizedUrl = Utils.normalizeUrl(url)
+
+                    val newAccount = account.copy(
+                        url = normalizedUrl,
+                        name = name,
+                        login = login,
+                        password = password,
+                        type = account.type,
+                        isCurrentAccount = true
+                    )
 
-                val newAccount = account.copy(
-                    url = normalizedUrl,
-                    accountName = name,
-                    login = login,
-                    password = password,
-                    accountType = account.accountType,
-                    isCurrentAccount = true
-                )
-
-                val repository = get { parametersOf(newAccount) }
-
-                screenModelScope.launch(dispatcher) {
                     try {
-                        repository.login(newAccount)
+                        get { parametersOf(newAccount) }
+                            .login(newAccount)
                     } catch (e: Exception) {
                         mutableState.update {
                             it.copy(
@@ -105,6 +106,8 @@ class AccountCredentialsScreenModel(
     }
 
     private fun validateFields(): Boolean = with(mutableState.value) {
+        mutableState.update { it.copy(loginException = null) }
+
         var validate = true
 
         if (url.isEmpty()) {
@@ -127,13 +130,38 @@ class AccountCredentialsScreenModel(
             validate = false
         }
 
-        if (url.isNotEmpty() && !Patterns.WEB_URL.matcher(url).matches()) {
-            mutableState.update { it.copy(urlError = TextFieldError.BadUrl) }
-            validate = false
-        }
-
         return validate
     }
+
+    companion object {
+        fun initAccountCredentialsState(account: Account, context: Context): AccountCredentialsState = when(account.type) {
+            AccountType.NEXTCLOUD_NEWS -> AccountCredentialsState(
+                url = context.getString(R.string.debug_nextcloud_news_url),
+                login = context.getString(R.string.debug_nextcloud_news_login),
+                password = context.getString(R.string.debug_nextcloud_news_password),
+            )
+            AccountType.FRESHRSS -> AccountCredentialsState(
+                url = context.getString(R.string.debug_freshrss_url),
+                login = context.getString(R.string.debug_freshrss_login),
+                password = context.getString(R.string.debug_freshrss_password),
+            )
+            AccountType.FEVER -> AccountCredentialsState(
+                url = context.getString(R.string.debug_fever_url),
+                login = context.getString(R.string.debug_fever_login),
+                password = context.getString(R.string.debug_fever_password),
+            )
+            AccountType.GREADER -> AccountCredentialsState(
+                url = context.getString(R.string.debug_greader_url),
+                login = context.getString(R.string.debug_greader_login),
+                password = context.getString(R.string.debug_greader_password),
+            )
+            null, AccountType.FEEDLY, AccountType.LOCAL -> AccountCredentialsState(
+                url = context.getString(R.string.debug_local_url),
+                login = context.getString(R.string.debug_local_login),
+                password = context.getString(R.string.debug_local_password),
+            )
+        }
+    }
 }
 
 data class AccountCredentialsState(
diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt b/app/src/main/java/com/readrops/app/account/dialog/AccountSelectionDialog.kt
similarity index 52%
rename from app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt
rename to app/src/main/java/com/readrops/app/account/dialog/AccountSelectionDialog.kt
index 2a5b63f65..784b6e878 100644
--- a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionDialog.kt
+++ b/app/src/main/java/com/readrops/app/account/dialog/AccountSelectionDialog.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.account.selection
+package com.readrops.app.account.dialog
 
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
@@ -6,8 +6,9 @@ import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import com.readrops.app.R
-import com.readrops.app.util.components.dialog.BaseDialog
+import com.readrops.app.account.selection.adaptiveIconPainterResource
 import com.readrops.app.util.components.SelectableImageText
+import com.readrops.app.util.components.dialog.BaseDialog
 import com.readrops.app.util.theme.spacing
 import com.readrops.db.entities.account.AccountType
 
@@ -21,16 +22,17 @@ fun AccountSelectionDialog(
         icon = painterResource(id = R.drawable.ic_add_account),
         onDismiss = onDismiss
     ) {
-        AccountType.entries.forEach { type ->
-            SelectableImageText(
-                image = adaptiveIconPainterResource(id = type.iconRes),
-                text = stringResource(id = type.typeName),
-                style = MaterialTheme.typography.titleMedium,
-                spacing = MaterialTheme.spacing.mediumSpacing,
-                padding = MaterialTheme.spacing.shortSpacing,
-                imageSize = 36.dp,
-                onClick = { onValidate(type) }
-            )
-        }
+        AccountType.entries
+            .forEach { type ->
+                SelectableImageText(
+                    image = adaptiveIconPainterResource(id = type.iconRes),
+                    text = stringResource(id = type.nameRes),
+                    style = MaterialTheme.typography.titleMedium,
+                    spacing = MaterialTheme.spacing.mediumSpacing,
+                    padding = MaterialTheme.spacing.shortSpacing,
+                    imageSize = 36.dp,
+                    onClick = { onValidate(type) }
+                )
+            }
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/account/dialog/AccountWarningDialog.kt b/app/src/main/java/com/readrops/app/account/dialog/AccountWarningDialog.kt
new file mode 100644
index 000000000..b5ac08edd
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/account/dialog/AccountWarningDialog.kt
@@ -0,0 +1,43 @@
+package com.readrops.app.account.dialog
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import com.readrops.app.R
+import com.readrops.app.util.DefaultPreview
+import com.readrops.app.util.components.dialog.TwoChoicesDialog
+import com.readrops.app.util.theme.ReadropsTheme
+import com.readrops.db.entities.account.AccountType
+
+@Composable
+fun AccountWarningDialog(
+    type: AccountType,
+    onConfirm: () -> Unit,
+    onDismiss: () -> Unit
+) {
+    TwoChoicesDialog(
+        title = stringResource(R.string.warning),
+        icon = painterResource(R.drawable.ic_warning),
+        text = when (type) {
+            AccountType.GREADER -> stringResource(R.string.greader_warning)
+            AccountType.FEVER -> stringResource(R.string.fever_warning)
+            else -> throw IllegalArgumentException("Account type not supported")
+        },
+        confirmText = stringResource(R.string.understand),
+        dismissText = stringResource(R.string.back),
+        onConfirm = onConfirm,
+        onDismiss = onDismiss
+    )
+}
+
+@DefaultPreview
+@Composable
+private fun AccountWarningDialogPreview() {
+    ReadropsTheme {
+        AccountWarningDialog(
+            type = AccountType.FEVER,
+            onConfirm = {},
+            onDismiss = {}
+        )
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt b/app/src/main/java/com/readrops/app/account/dialog/OPMLChoiceDialog.kt
similarity index 97%
rename from app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt
rename to app/src/main/java/com/readrops/app/account/dialog/OPMLChoiceDialog.kt
index bb1f02a57..7f501aa0a 100644
--- a/app/src/main/java/com/readrops/app/account/OPMLChoiceDialog.kt
+++ b/app/src/main/java/com/readrops/app/account/dialog/OPMLChoiceDialog.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.account
+package com.readrops.app.account.dialog
 
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
diff --git a/app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt b/app/src/main/java/com/readrops/app/account/dialog/OPMLImportProgressDialog.kt
similarity index 94%
rename from app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt
rename to app/src/main/java/com/readrops/app/account/dialog/OPMLImportProgressDialog.kt
index b828de4bd..479ad27dd 100644
--- a/app/src/main/java/com/readrops/app/account/OPMLImportProgressDialog.kt
+++ b/app/src/main/java/com/readrops/app/account/dialog/OPMLImportProgressDialog.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.account
+package com.readrops.app.account.dialog
 
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.res.painterResource
diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt
index b25f25318..9eab1616c 100644
--- a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt
+++ b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreen.kt
@@ -33,23 +33,25 @@ import androidx.compose.ui.unit.dp
 import androidx.core.content.res.ResourcesCompat
 import androidx.core.graphics.drawable.toBitmap
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.koin.koinScreenModel
 import cafe.adriel.voyager.navigator.LocalNavigator
 import cafe.adriel.voyager.navigator.currentOrThrow
 import com.readrops.api.utils.ApiUtils
 import com.readrops.app.BuildConfig
+import com.readrops.app.MainActivity
 import com.readrops.app.R
-import com.readrops.app.account.OPMLImportProgressDialog
 import com.readrops.app.account.credentials.AccountCredentialsScreen
 import com.readrops.app.account.credentials.AccountCredentialsScreenMode
+import com.readrops.app.account.dialog.AccountWarningDialog
+import com.readrops.app.account.dialog.OPMLImportProgressDialog
 import com.readrops.app.home.HomeScreen
-import com.readrops.app.util.ErrorMessage
 import com.readrops.app.util.components.AndroidScreen
 import com.readrops.app.util.components.SelectableImageText
 import com.readrops.app.util.theme.LargeSpacer
 import com.readrops.app.util.theme.MediumSpacer
 import com.readrops.app.util.theme.ShortSpacer
 import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.account.ACCOUNT_APIS
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
 
@@ -60,7 +62,7 @@ class AccountSelectionScreen : AndroidScreen() {
         val navigator = LocalNavigator.currentOrThrow
         val context = LocalContext.current
 
-        val screenModel = getScreenModel()
+        val screenModel = koinScreenModel()
         val state by screenModel.state.collectAsStateWithLifecycle()
 
         val opmlImportLauncher =
@@ -70,39 +72,58 @@ class AccountSelectionScreen : AndroidScreen() {
 
         val snackbarHostState = remember { SnackbarHostState() }
 
-        if (state.showOPMLImportDialog) {
-            OPMLImportProgressDialog(
-                currentFeed = state.currentFeed,
-                feedCount = state.feedCount,
-                feedMax = state.feedMax
-            )
+        // remove splash screen when opening the app with no account available
+        LaunchedEffect(Unit) {
+            (context as MainActivity).ready = true
         }
 
-        LaunchedEffect(state.exception) {
-            if (state.exception != null) {
-                snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context))
+        LaunchedEffect(state.error) {
+            if (state.error != null) {
+                snackbarHostState.showSnackbar(state.error!!)
                 screenModel.resetException()
             }
         }
 
-        when (state.navState) {
-            is NavState.GoToHomeScreen -> {
+        when (state.navigation) {
+            is Navigation.HomeScreen -> {
                 // using replace makes the app crash due to a screen key conflict
                 navigator.replaceAll(HomeScreen)
             }
 
-            is NavState.GoToAccountCredentialsScreen -> {
-                val accountType =
-                    (state.navState as NavState.GoToAccountCredentialsScreen).accountType
+            is Navigation.AccountCredentialsScreen -> {
+                val type = (state.navigation as Navigation.AccountCredentialsScreen).type
                 val account = Account(
-                    accountType = accountType,
-                    accountName = stringResource(id = accountType.typeName)
+                    type = type,
+                    name = stringResource(id = type.nameRes)
                 )
 
                 navigator.push(
                     AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS)
                 )
-                screenModel.resetNavState()
+                screenModel.resetNavigation()
+            }
+
+            else -> {}
+        }
+
+        when (val dialog = state.dialog) {
+            is DialogState.AccountWarning -> {
+                AccountWarningDialog(
+                    type = dialog.type,
+                    onConfirm = {
+                        screenModel.createAccount(dialog.type)
+                        screenModel.closeDialog()
+                    },
+                    onDismiss = { screenModel.closeDialog() }
+                )
+            }
+
+            is DialogState.OPMLImport -> {
+                OPMLImportProgressDialog(
+                    currentFeed = state.currentFeed,
+                    feedCount = state.feedCount,
+                    feedMax = state.feedMax
+                )
             }
 
             else -> {}
@@ -159,7 +180,7 @@ class AccountSelectionScreen : AndroidScreen() {
 
                             SelectableImageText(
                                 image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
-                                text = stringResource(id = AccountType.LOCAL.typeName),
+                                text = stringResource(id = AccountType.LOCAL.nameRes),
                                 style = MaterialTheme.typography.bodyLarge,
                                 spacing = MaterialTheme.spacing.mediumSpacing,
                                 padding = MaterialTheme.spacing.mediumSpacing,
@@ -185,11 +206,11 @@ class AccountSelectionScreen : AndroidScreen() {
                                 modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
                             )
 
-                            AccountType.entries.filter { it != AccountType.LOCAL }
+                            listOf(AccountType.FRESHRSS, AccountType.NEXTCLOUD_NEWS)
                                 .forEach { accountType ->
                                     SelectableImageText(
                                         image = adaptiveIconPainterResource(id = accountType.iconRes),
-                                        text = stringResource(id = accountType.typeName),
+                                        text = stringResource(id = accountType.nameRes),
                                         style = MaterialTheme.typography.bodyLarge,
                                         imageSize = 24.dp,
                                         spacing = MaterialTheme.spacing.mediumSpacing,
@@ -198,7 +219,29 @@ class AccountSelectionScreen : AndroidScreen() {
                                     )
                                 }
 
+                            MediumSpacer()
 
+                            Text(
+                                text = stringResource(R.string.api),
+                                style = MaterialTheme.typography.bodyMedium,
+                                modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
+                            )
+
+                            ACCOUNT_APIS.forEach { accountType ->
+                                    SelectableImageText(
+                                        image = adaptiveIconPainterResource(id = accountType.iconRes),
+                                        text = stringResource(id = accountType.nameRes),
+                                        style = MaterialTheme.typography.bodyLarge,
+                                        imageSize = 24.dp,
+                                        spacing = MaterialTheme.spacing.mediumSpacing,
+                                        padding = MaterialTheme.spacing.mediumSpacing,
+                                        onClick = {
+                                            screenModel.openDialog(
+                                                DialogState.AccountWarning(accountType)
+                                            )
+                                        }
+                                    )
+                                }
                         }
                     }
                 }
diff --git a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt
index 1e7a18587..6b84717c0 100644
--- a/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/account/selection/AccountSelectionScreenModel.kt
@@ -6,7 +6,9 @@ import androidx.core.net.toFile
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
 import com.readrops.api.opml.OPMLParser
+import com.readrops.app.R
 import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.util.accounterror.AccountError
 import com.readrops.db.Database
 import com.readrops.db.entities.Feed
 import com.readrops.db.entities.Folder
@@ -23,9 +25,12 @@ import org.koin.core.parameter.parametersOf
 
 class AccountSelectionScreenModel(
     private val database: Database,
+    context: Context,
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
 ) : StateScreenModel(AccountSelectionState()), KoinComponent {
 
+    private val accountError = AccountError.Companion.DefaultAccountError(context)
+
     fun accountExists(): Boolean {
         val accountCount = runBlocking {
             database.accountDao().selectAccountCount()
@@ -38,25 +43,25 @@ class AccountSelectionScreenModel(
         if (accountType == AccountType.LOCAL) {
             screenModelScope.launch(dispatcher) {
                 createLocalAccount()
-                mutableState.update { it.copy(navState = NavState.GoToHomeScreen) }
+                mutableState.update { it.copy(navigation = Navigation.HomeScreen) }
             }
         } else {
             mutableState.update {
-                it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType))
+                it.copy(navigation = Navigation.AccountCredentialsScreen(accountType))
             }
         }
     }
 
-    fun resetNavState() {
-        mutableState.update { it.copy(navState = NavState.Idle) }
+    fun resetNavigation() {
+        mutableState.update { it.copy(navigation = null) }
     }
 
     private suspend fun createLocalAccount(): Account {
         val context = get()
         val account = Account(
             url = null,
-            accountName = context.getString(AccountType.LOCAL.typeName),
-            accountType = AccountType.LOCAL,
+            name = context.getString(AccountType.LOCAL.nameRes),
+            type = AccountType.LOCAL,
             isCurrentAccount = true
         )
 
@@ -71,19 +76,24 @@ class AccountSelectionScreenModel(
             try {
                 val stream = context.contentResolver.openInputStream(uri)
                 if (stream == null) {
-                    mutableState.update { it.copy(exception = NoSuchFileException(uri.toFile())) }
+                    mutableState.update { it.copy(error = accountError.genericMessage(NoSuchFileException(uri.toFile()))) }
                     return@launch
                 }
 
                 foldersAndFeeds = OPMLParser.read(stream)
             } catch (e: Exception) {
-                mutableState.update { it.copy(exception = e) }
+                mutableState.update { it.copy(error = accountError.genericMessage(e)) }
+                return@launch
+            }
+
+            if (foldersAndFeeds.isEmpty()) {
+                mutableState.update { it.copy(error = context.getString(R.string.empty_file)) }
                 return@launch
             }
 
             mutableState.update {
                 it.copy(
-                    showOPMLImportDialog = true,
+                    dialog = DialogState.OPMLImport,
                     currentFeed = foldersAndFeeds.values.first().first().name,
                     feedCount = 0,
                     feedMax = foldersAndFeeds.values.flatten().size
@@ -107,27 +117,35 @@ class AccountSelectionScreenModel(
 
             mutableState.update {
                 it.copy(
-                    showOPMLImportDialog = false,
-                    navState = NavState.GoToHomeScreen
+                    dialog = null,
+                    navigation = Navigation.HomeScreen
                 )
             }
         }
     }
 
-    fun resetException() = mutableState.update { it.copy(exception = null) }
+    fun resetException() = mutableState.update { it.copy(error = null) }
+
+    fun openDialog(dialog: DialogState) = mutableState.update { it.copy(dialog = dialog) }
+
+    fun closeDialog() = mutableState.update { it.copy(dialog = null) }
 }
 
 data class AccountSelectionState(
-    val showOPMLImportDialog: Boolean = false,
-    val navState: NavState = NavState.Idle,
-    val exception: Exception? = null,
+    val error: String? = null,
     val currentFeed: String? = null,
     val feedCount: Int = 0,
-    val feedMax: Int = 0
+    val feedMax: Int = 0,
+    val dialog: DialogState? = null,
+    val navigation: Navigation? = null
 )
 
-sealed class NavState {
-    data object Idle : NavState()
-    data object GoToHomeScreen : NavState()
-    class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
+sealed class Navigation {
+    data object HomeScreen : Navigation()
+    data class AccountCredentialsScreen(val type: AccountType) : Navigation()
+}
+
+sealed interface DialogState {
+    data object OPMLImport : DialogState
+    data class AccountWarning(val type: AccountType) : DialogState
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt b/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt
index 7c70257ce..c93215e48 100644
--- a/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/feeds/FeedScreenModel.kt
@@ -1,23 +1,18 @@
 package com.readrops.app.feeds
 
 import android.content.Context
-import android.content.SharedPreferences
 import android.util.Patterns
 import cafe.adriel.voyager.core.model.screenModelScope
-import com.readrops.api.localfeed.LocalRSSDataSource
-import com.readrops.api.services.Credentials
-import com.readrops.api.utils.AuthInterceptor
-import com.readrops.api.utils.HtmlParser
 import com.readrops.app.R
-import com.readrops.app.base.TabScreenModel
-import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.home.TabScreenModel
 import com.readrops.app.repositories.GetFoldersWithFeeds
 import com.readrops.app.util.components.TextFieldError
 import com.readrops.app.util.components.dialog.TextFieldDialogState
+import com.readrops.app.util.extensions.isConnected
 import com.readrops.db.Database
 import com.readrops.db.entities.Feed
 import com.readrops.db.entities.Folder
-import com.readrops.db.entities.account.Account
+import com.readrops.db.entities.OpenIn
 import com.readrops.db.filters.MainFilter
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
@@ -29,25 +24,18 @@ import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
-import org.koin.core.parameter.parametersOf
-import java.net.UnknownHostException
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class FeedScreenModel(
-    database: Database,
+    private val database: Database,
     private val getFoldersWithFeeds: GetFoldersWithFeeds,
-    private val localRSSDataSource: LocalRSSDataSource,
     private val context: Context,
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
-) : TabScreenModel(database), KoinComponent {
+) : TabScreenModel(database, context), KoinComponent {
 
     private val _feedState = MutableStateFlow(FeedState())
     val feedsState = _feedState.asStateFlow()
 
-    private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
-    val addFeedDialogState = _addFeedDialogState.asStateFlow()
-
     private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState())
     val updateFeedDialogState = _updateFeedDialogState.asStateFlow()
 
@@ -58,7 +46,10 @@ class FeedScreenModel(
         screenModelScope.launch(dispatcher) {
             accountEvent.flatMapLatest { account ->
                 _feedState.update {
-                    it.copy(config = account.config)
+                    it.copy(
+                        isAccountNotificationsEnabled = account.isNotificationsEnabled,
+                        config = account.config
+                    )
                 }
 
                 _updateFeedDialogState.update {
@@ -79,24 +70,29 @@ class FeedScreenModel(
                     }
                 }
                 .collect { foldersAndFeeds ->
-                    _feedState.update {
-                        it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds))
-                    }
-                }
-        }
+                    _feedState.update { state ->
+                        val dialog = when (state.dialog) {
+                            is DialogState.FeedSheet -> {
+                                val feed = foldersAndFeeds.values.flatten()
+                                    .first { it.id == state.dialog.feed.id }
+                                state.dialog.copy(feed = feed)
+                            }
 
-        screenModelScope.launch(dispatcher) {
-            database.accountDao()
-                .selectAllAccounts()
-                .collect { accounts ->
-                    if (accounts.isNotEmpty()) {
-                        _addFeedDialogState.update { dialogState ->
-                            dialogState.copy(
-                                accounts = accounts,
-                                selectedAccount = accounts.find { it.isCurrentAccount }
-                                    ?: accounts.first()
-                            )
+                            is DialogState.UpdateFeedOpenInSetting -> {
+                                val feed = foldersAndFeeds.values.flatten()
+                                    .first { it.id == state.dialog.feed.id }
+                                state.dialog.copy(feed = feed)
+                            }
+
+                            else -> {
+                                state.dialog
+                            }
                         }
+
+                        state.copy(
+                            foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds),
+                            dialog = dialog
+                        )
                     }
                 }
         }
@@ -135,36 +131,37 @@ class FeedScreenModel(
 
     fun closeDialog(dialog: DialogState? = null) {
         when (dialog) {
-            is DialogState.AddFeed -> {
-                _addFeedDialogState.update {
-                    it.copy(
-                        url = "",
-                        error = null,
-                        exception = null,
-                        isLoading = false
-                    )
-                }
-            }
-
             is DialogState.AddFolder, is DialogState.UpdateFolder -> {
                 _folderState.update {
                     it.copy(
                         value = "",
                         textFieldError = null,
-                        exception = null,
+                        error = null,
                         isLoading = false
                     )
                 }
             }
 
             is DialogState.UpdateFeed -> {
-                _updateFeedDialogState.update { it.copy(exception = null, isLoading = false) }
+                _updateFeedDialogState.update { it.copy(error = null, isLoading = false) }
             }
 
             else -> {}
         }
 
-        _feedState.update { it.copy(dialog = null) }
+        if (dialog is DialogState.UpdateFeedOpenInSetting) {
+            _feedState.update {
+                it.copy(
+                    dialog = DialogState.FeedSheet(
+                        feed = dialog.feed,
+                        folder = null,
+                        config = currentAccount!!.config
+                    )
+                )
+            }
+        } else {
+            _feedState.update { it.copy(dialog = null) }
+        }
     }
 
     fun openDialog(state: DialogState) {
@@ -190,12 +187,6 @@ class FeedScreenModel(
                 }
             }
 
-            is DialogState.AddFeed -> {
-                _addFeedDialogState.update {
-                    it.copy(url = state.url.orEmpty())
-                }
-            }
-
             else -> {}
         }
 
@@ -203,141 +194,33 @@ class FeedScreenModel(
     }
 
     fun deleteFeed(feed: Feed) {
+        if (!checkInternetConnection()) {
+            return
+        }
+
         screenModelScope.launch(dispatcher) {
             try {
                 repository?.deleteFeed(feed)
             } catch (e: Exception) {
-                _feedState.update { it.copy(exception = e) }
+                _feedState.update { it.copy(error = accountError?.deleteFeedMessage(e)) }
             }
         }
     }
 
     fun deleteFolder(folder: Folder) {
+        if (!checkInternetConnection()) {
+            return
+        }
+
         screenModelScope.launch(dispatcher) {
             try {
                 repository?.deleteFolder(folder)
             } catch (e: Exception) {
-                _feedState.update { it.copy(exception = e) }
-            }
-        }
-    }
-
-    //region Add Feed
-
-    fun setAddFeedDialogURL(url: String) {
-        _addFeedDialogState.update {
-            it.copy(
-                url = url,
-                error = null,
-            )
-        }
-    }
-
-    fun setAddFeedDialogSelectedAccount(account: Account) {
-        _addFeedDialogState.update {
-            it.copy(
-                selectedAccount = account,
-                isAccountDropDownExpanded = false
-            )
-        }
-    }
-
-    fun setAccountDropDownExpanded(isExpanded: Boolean) {
-        _addFeedDialogState.update { it.copy(isAccountDropDownExpanded = isExpanded) }
-    }
-
-    fun addFeedDialogValidate() {
-        val url = _addFeedDialogState.value.url
-
-        when {
-            url.isEmpty() -> {
-                _addFeedDialogState.update {
-                    it.copy(error = TextFieldError.EmptyField)
-                }
-
-                return
-            }
-
-            !Patterns.WEB_URL.matcher(url).matches() -> {
-                _addFeedDialogState.update {
-                    it.copy(error = TextFieldError.BadUrl)
-                }
-
-                return
-            }
-
-            else -> screenModelScope.launch(dispatcher) {
-                _addFeedDialogState.update { it.copy(exception = null, isLoading = true) }
-
-                try {
-                    if (localRSSDataSource.isUrlRSSResource(url)) {
-                        insertFeeds(listOf(Feed(url = url)))
-                    } else {
-                        val rssUrls = HtmlParser.getFeedLink(url, get())
-
-                        if (rssUrls.isEmpty()) {
-                            _addFeedDialogState.update {
-                                it.copy(error = TextFieldError.NoRSSFeed, isLoading = false)
-                            }
-                        } else {
-                            insertFeeds(rssUrls.map { Feed(url = it.url) })
-                        }
-                    }
-                } catch (e: Exception) {
-                    when (e) {
-                        is UnknownHostException -> _addFeedDialogState.update {
-                            it.copy(
-                                error = TextFieldError.UnreachableUrl,
-                                isLoading = false
-                            )
-                        }
-
-                        else -> _addFeedDialogState.update {
-                            it.copy(
-                                error = TextFieldError.NoRSSFeed,
-                                isLoading = false
-                            )
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    private suspend fun insertFeeds(feeds: List) {
-        val selectedAccount = _addFeedDialogState.value.selectedAccount
-
-        if (!selectedAccount.isLocal) {
-            get().apply {
-                selectedAccount.login = getString(selectedAccount.loginKey, null)
-                selectedAccount.password = getString(selectedAccount.passwordKey, null)
-            }
-            get().apply {
-                credentials = Credentials.toCredentials(selectedAccount)
-            }
-        }
-
-        val repository = get { parametersOf(selectedAccount) }
-
-        val errors = repository.insertNewFeeds(
-            newFeeds = feeds,
-            onUpdate = { /* TODO */ }
-        )
-
-        if (errors.isEmpty()) {
-            closeDialog(_feedState.value.dialog)
-        } else {
-            _addFeedDialogState.update {
-                it.copy(
-                    exception = errors.values.first(),
-                    isLoading = false
-                )
+                _feedState.update { it.copy(error = accountError?.deleteFolderMessage(e)) }
             }
         }
     }
 
-    //endregion
-
     //region Update feed
 
     fun setFolderDropDownState(isExpanded: Boolean) {
@@ -397,7 +280,12 @@ class FeedScreenModel(
             }
 
             else -> {
-                _updateFeedDialogState.update { it.copy(exception = null, isLoading = true) }
+                if (!context.isConnected()) {
+                    _updateFeedDialogState.update { it.copy(error = context.getString(R.string.no_network)) }
+                    return
+                } else {
+                    _updateFeedDialogState.update { it.copy(error = null, isLoading = true) }
+                }
 
                 screenModelScope.launch(dispatcher) {
                     with(_updateFeedDialogState.value) {
@@ -417,7 +305,7 @@ class FeedScreenModel(
                         } catch (e: Exception) {
                             _updateFeedDialogState.update {
                                 it.copy(
-                                    exception = e,
+                                    error = accountError?.updateFeedMessage(e),
                                     isLoading = false
                                 )
                             }
@@ -443,7 +331,6 @@ class FeedScreenModel(
     }
 
     fun folderValidate(updateFolder: Boolean = false) {
-        _folderState.update { it.copy(isLoading = true) }
         val name = _folderState.value.value
 
         if (name.isEmpty()) {
@@ -456,6 +343,13 @@ class FeedScreenModel(
             return
         }
 
+        if (!context.isConnected()) {
+            _folderState.update { it.copy(error = context.getString(R.string.no_network)) }
+            return
+        } else {
+            _folderState.update { it.copy(isLoading = true) }
+        }
+
         screenModelScope.launch(dispatcher) {
             try {
                 if (updateFolder) {
@@ -465,7 +359,16 @@ class FeedScreenModel(
                     repository?.addFolder(Folder(name = name, accountId = currentAccount!!.id))
                 }
             } catch (e: Exception) {
-                _folderState.update { it.copy(exception = e, isLoading = false) }
+                _folderState.update {
+                    it.copy(
+                        error = if (updateFolder) {
+                            accountError?.updateFolderMessage(e)
+                        } else {
+                            accountError?.newFolderMessage(e)
+                        },
+                        isLoading = false
+                    )
+                }
                 return@launch
             }
 
@@ -473,7 +376,33 @@ class FeedScreenModel(
         }
     }
 
-    fun resetException() = _feedState.update { it.copy(exception = null) }
-
     //endregion
+
+    fun resetException() = _feedState.update { it.copy(error = null) }
+
+    fun updateFeedNotifications(feedId: Int, isEnabled: Boolean) {
+        screenModelScope.launch(dispatcher) {
+            database.feedDao().updateFeedNotificationState(feedId, isEnabled)
+        }
+    }
+
+    fun updateFeedOpenInSetting(feedId: Int, openIn: OpenIn) {
+        screenModelScope.launch(dispatcher) {
+            database.feedDao().updateOpenInSetting(feedId, openIn)
+        }
+    }
+
+    private fun checkInternetConnection(): Boolean {
+        if (!currentAccount!!.isLocal) {
+            return true
+        }
+
+        val isConnected = context.isConnected()
+
+        if (!isConnected) {
+            _feedState.update { it.copy(error = context.getString(R.string.no_network)) }
+        }
+
+        return isConnected
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/FeedState.kt b/app/src/main/java/com/readrops/app/feeds/FeedState.kt
index cb00cae6a..fabfebb2f 100644
--- a/app/src/main/java/com/readrops/app/feeds/FeedState.kt
+++ b/app/src/main/java/com/readrops/app/feeds/FeedState.kt
@@ -3,15 +3,15 @@ package com.readrops.app.feeds
 import com.readrops.app.util.components.TextFieldError
 import com.readrops.db.entities.Feed
 import com.readrops.db.entities.Folder
-import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountConfig
 
 data class FeedState(
     val foldersAndFeeds: FolderAndFeedsState = FolderAndFeedsState.InitialState,
     val dialog: DialogState? = null,
     val areFoldersExpanded: Boolean = false,
-    val exception: Exception? = null,
-    val config: AccountConfig? = null
+    val error: String? = null,
+    val config: AccountConfig? = null,
+    val isAccountNotificationsEnabled: Boolean = false
 ) {
 
     val displayThreeDotsMenu
@@ -19,12 +19,12 @@ data class FeedState(
 }
 
 sealed interface DialogState {
-    data class AddFeed(val url: String? = null) : DialogState
     data object AddFolder : DialogState
     class DeleteFeed(val feed: Feed) : DialogState
     class DeleteFolder(val folder: Folder) : DialogState
     class UpdateFeed(val feed: Feed, val folder: Folder?) : DialogState
     class UpdateFolder(val folder: Folder) : DialogState
+    data class UpdateFeedOpenInSetting(val feed: Feed) : DialogState
 
     data class FeedSheet(
         val feed: Feed,
@@ -39,18 +39,6 @@ sealed class FolderAndFeedsState {
     data class LoadedState(val values: Map>) : FolderAndFeedsState()
 }
 
-data class AddFeedDialogState(
-    val url: String = "",
-    val selectedAccount: Account = Account(accountName = ""),
-    val accounts: List = listOf(),
-    val error: TextFieldError? = null,
-    val exception: Exception? = null,
-    val isLoading: Boolean = false,
-    val isAccountDropDownExpanded: Boolean = false
-) {
-    val isError: Boolean get() = error != null
-}
-
 data class UpdateFeedDialogState(
     val feedId: Int = 0,
     val feedRemoteId: String? = null,
@@ -62,7 +50,7 @@ data class UpdateFeedDialogState(
     val folders: List = listOf(),
     val isFolderDropDownExpanded: Boolean = false,
     val isFeedUrlReadOnly: Boolean = true,
-    val exception: Exception? = null,
+    val error: String? = null,
     val isLoading: Boolean = false
 ) {
     val isFeedNameError
diff --git a/app/src/main/java/com/readrops/app/feeds/FeedTab.kt b/app/src/main/java/com/readrops/app/feeds/FeedTab.kt
index 55b93f9ad..0d2605174 100644
--- a/app/src/main/java/com/readrops/app/feeds/FeedTab.kt
+++ b/app/src/main/java/com/readrops/app/feeds/FeedTab.kt
@@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.FloatingActionButton
 import androidx.compose.material3.Icon
@@ -29,7 +28,6 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.rememberVectorPainter
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
@@ -39,19 +37,20 @@ import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.koin.koinScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
 import cafe.adriel.voyager.navigator.tab.Tab
 import cafe.adriel.voyager.navigator.tab.TabOptions
+import com.readrops.app.MainActivity
 import com.readrops.app.R
-import com.readrops.app.feeds.dialogs.AddFeedDialog
-import com.readrops.app.feeds.dialogs.FeedModalBottomSheet
-import com.readrops.app.feeds.dialogs.UpdateFeedDialog
-import com.readrops.app.util.ErrorMessage
+import com.readrops.app.feeds.components.FeedItem
+import com.readrops.app.feeds.components.FolderExpandableItem
+import com.readrops.app.feeds.dialogs.FeedDialogs
+import com.readrops.app.feeds.newfeed.NewFeedScreen
 import com.readrops.app.util.components.CenteredProgressIndicator
 import com.readrops.app.util.components.ErrorMessage
 import com.readrops.app.util.components.Placeholder
-import com.readrops.app.util.components.dialog.TextFieldDialog
-import com.readrops.app.util.components.dialog.TwoChoicesDialog
 import com.readrops.app.util.theme.spacing
 import com.readrops.db.entities.Feed
 import kotlinx.coroutines.channels.Channel
@@ -73,18 +72,24 @@ object FeedTab : Tab {
     override fun Content() {
         val haptic = LocalHapticFeedback.current
         val uriHandler = LocalUriHandler.current
+        val navigator = LocalNavigator.currentOrThrow
         val context = LocalContext.current
 
-        val screenModel = getScreenModel()
+        val screenModel = koinScreenModel()
         val state by screenModel.feedsState.collectAsStateWithLifecycle()
 
         val snackbarHostState = remember { SnackbarHostState() }
         val topAppBarScrollBehavior =
             TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
 
-        LaunchedEffect(state.exception) {
-            if (state.exception != null) {
-                snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context))
+        // remove splash screen when opening the app from  intent
+        LaunchedEffect(Unit) {
+            (context as MainActivity).ready = true
+        }
+
+        LaunchedEffect(state.error) {
+            if (state.error != null) {
+                snackbarHostState.showSnackbar((state.error!!))
                 screenModel.resetException()
             }
         }
@@ -93,7 +98,7 @@ object FeedTab : Tab {
             addFeedDialogChannel.receiveAsFlow()
                 .collect { url ->
                     if (Patterns.WEB_URL.matcher(url).matches()) {
-                        screenModel.openDialog(DialogState.AddFeed(url))
+                        navigator.push(NewFeedScreen(url))
                     }
                 }
         }
@@ -146,7 +151,7 @@ object FeedTab : Tab {
 
                     if (state.config?.canCreateFeed == true) {
                         FloatingActionButton(
-                            onClick = { screenModel.openDialog(DialogState.AddFeed()) }
+                            onClick = { navigator.push(NewFeedScreen()) }
                         ) {
                             Icon(
                                 imageVector = Icons.Default.Add,
@@ -252,106 +257,6 @@ object FeedTab : Tab {
         }
     }
 
-    @Composable
-    private fun FeedDialogs(state: FeedState, screenModel: FeedScreenModel) {
-        val uriHandler = LocalUriHandler.current
-
-        val addFeedDialogState by screenModel.addFeedDialogState.collectAsStateWithLifecycle()
-        val folderState by screenModel.folderState.collectAsStateWithLifecycle()
-
-        when (val dialog = state.dialog) {
-            is DialogState.AddFeed -> {
-                AddFeedDialog(
-                    state = addFeedDialogState,
-                    onValueChange = { screenModel.setAddFeedDialogURL(it) },
-                    onExpandChange = { screenModel.setAccountDropDownExpanded(it) },
-                    onAccountClick = { screenModel.setAddFeedDialogSelectedAccount(it) },
-                    onValidate = { screenModel.addFeedDialogValidate() },
-                    onDismiss = { screenModel.closeDialog(DialogState.AddFeed()) },
-                )
-            }
-
-            is DialogState.DeleteFeed -> {
-                TwoChoicesDialog(
-                    title = stringResource(R.string.delete_feed),
-                    text = stringResource(R.string.delete_feed_question, dialog.feed.name!!),
-                    icon = rememberVectorPainter(image = Icons.Default.Delete),
-                    confirmText = stringResource(R.string.delete),
-                    dismissText = stringResource(R.string.cancel),
-                    onDismiss = { screenModel.closeDialog() },
-                    onConfirm = {
-                        screenModel.deleteFeed(dialog.feed)
-                        screenModel.closeDialog()
-                    }
-                )
-            }
-
-            is DialogState.FeedSheet -> {
-                FeedModalBottomSheet(
-                    feed = dialog.feed,
-                    onDismissRequest = { screenModel.closeDialog() },
-                    onOpen = {
-                        uriHandler.openUri(dialog.feed.siteUrl!!)
-                        screenModel.closeDialog()
-                    },
-                    onUpdate = {
-                        screenModel.openDialog(DialogState.UpdateFeed(dialog.feed, dialog.folder))
-                    },
-                    onDelete = { screenModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
-                    canUpdateFeed = dialog.config.canUpdateFeed,
-                    canDeleteFeed = dialog.config.canDeleteFeed
-                )
-            }
-
-            is DialogState.UpdateFeed -> {
-                UpdateFeedDialog(
-                    viewModel = screenModel,
-                    onDismissRequest = { screenModel.closeDialog(dialog) }
-                )
-            }
-
-            DialogState.AddFolder -> {
-                TextFieldDialog(
-                    title = stringResource(id = R.string.add_folder),
-                    icon = painterResource(id = R.drawable.ic_new_folder),
-                    label = stringResource(id = R.string.name),
-                    state = folderState,
-                    onValueChange = { screenModel.setFolderName(it) },
-                    onValidate = { screenModel.folderValidate() },
-                    onDismiss = { screenModel.closeDialog(DialogState.AddFolder) }
-                )
-            }
-
-            is DialogState.DeleteFolder -> {
-                TwoChoicesDialog(
-                    title = stringResource(R.string.delete_folder),
-                    text = stringResource(R.string.delete_folder_question, dialog.folder.name!!),
-                    icon = rememberVectorPainter(image = Icons.Default.Delete),
-                    confirmText = stringResource(R.string.delete),
-                    dismissText = stringResource(R.string.cancel),
-                    onDismiss = { screenModel.closeDialog() },
-                    onConfirm = {
-                        screenModel.deleteFolder(dialog.folder)
-                        screenModel.closeDialog()
-                    }
-                )
-            }
-
-            is DialogState.UpdateFolder -> {
-                TextFieldDialog(
-                    title = stringResource(id = R.string.edit_folder),
-                    icon = painterResource(id = R.drawable.ic_folder_grey),
-                    label = stringResource(id = R.string.name),
-                    state = folderState,
-                    onValueChange = { screenModel.setFolderName(it) },
-                    onValidate = { screenModel.folderValidate(updateFolder = true) },
-                    onDismiss = { screenModel.closeDialog(DialogState.UpdateFolder(dialog.folder)) }
-                )
-            }
-
-            null -> {}
-        }
-    }
 
     suspend fun openAddFeedDialog(url: String) {
         addFeedDialogChannel.send(url)
diff --git a/app/src/main/java/com/readrops/app/feeds/color/ColorPickerDialog.kt b/app/src/main/java/com/readrops/app/feeds/color/ColorPickerDialog.kt
new file mode 100644
index 000000000..2547c4d07
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/color/ColorPickerDialog.kt
@@ -0,0 +1,122 @@
+package com.readrops.app.feeds.color
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.skydoves.colorpicker.compose.AlphaSlider
+import com.github.skydoves.colorpicker.compose.BrightnessSlider
+import com.github.skydoves.colorpicker.compose.HsvColorPicker
+import com.github.skydoves.colorpicker.compose.rememberColorPickerController
+import com.readrops.app.R
+import com.readrops.app.util.DefaultPreview
+import com.readrops.app.util.components.dialog.BaseDialog
+import com.readrops.app.util.theme.LargeSpacer
+import com.readrops.app.util.theme.MediumSpacer
+import com.readrops.app.util.theme.ReadropsTheme
+import com.readrops.app.util.theme.ShortSpacer
+import com.readrops.app.util.theme.spacing
+
+@Composable
+fun ColorPickerDialog(
+    color: Color?,
+    onValidate: (Color) -> Unit,
+    onDismiss: () -> Unit,
+) {
+    val controller = rememberColorPickerController()
+
+    BaseDialog(
+        title = stringResource(R.string.select_color),
+        icon = painterResource(R.drawable.ic_color),
+        onDismiss = onDismiss
+    ) {
+        Column(
+            modifier = Modifier.background(MaterialTheme.colorScheme.background)
+        ) {
+            HsvColorPicker(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = MaterialTheme.spacing.mediumSpacing)
+                    .height(200.dp),
+                controller = controller,
+                initialColor = color
+            )
+
+            AlphaSlider(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = MaterialTheme.spacing.shortSpacing)
+                    .height(24.dp),
+                controller = controller,
+            )
+
+            BrightnessSlider(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = MaterialTheme.spacing.shortSpacing)
+                    .height(24.dp),
+                controller = controller,
+            )
+
+            MediumSpacer()
+
+            Box(
+                modifier = Modifier
+                    .background(controller.selectedColor.value, RectangleShape)
+                    .size(48.dp)
+                    .align(Alignment.CenterHorizontally)
+            )
+
+            LargeSpacer()
+
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.End,
+                modifier = Modifier.fillMaxWidth()
+            ) {
+                TextButton(
+                    onClick = onDismiss
+                ) {
+                    Text(text = stringResource(R.string.back))
+                }
+
+                ShortSpacer()
+
+                TextButton(
+                    onClick = { onValidate(controller.selectedColor.value) }
+                ) {
+                    Text(text = stringResource(R.string.validate))
+                }
+            }
+
+        }
+    }
+}
+
+@DefaultPreview
+@Composable
+private fun ColorPickerDialogPreview() {
+    ReadropsTheme {
+        ColorPickerDialog(
+            color = null,
+            onValidate = {},
+            onDismiss = {},
+        )
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/color/ColorPreview.kt b/app/src/main/java/com/readrops/app/feeds/color/ColorPreview.kt
new file mode 100644
index 000000000..81f42bb05
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/color/ColorPreview.kt
@@ -0,0 +1,79 @@
+package com.readrops.app.feeds.color
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.readrops.app.R
+import com.readrops.app.util.components.FeedIcon
+import com.readrops.app.util.components.IconText
+import com.readrops.app.util.extensions.canDisplayOnBackground
+import com.readrops.app.util.theme.ShortSpacer
+import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.Feed
+
+@Composable
+fun ColorPreview(
+    feed: Feed,
+    color: Color,
+    modifier: Modifier = Modifier
+) {
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .height(IntrinsicSize.Min)
+            .background(CardDefaults.cardColors().containerColor)
+            .padding(MaterialTheme.spacing.mediumSpacing)
+    ) {
+        Row(
+            verticalAlignment = Alignment.Companion.CenterVertically,
+        ) {
+            FeedIcon(
+                iconUrl = feed.iconUrl,
+                name = feed.name.orEmpty(),
+            )
+
+            ShortSpacer()
+
+            Text(
+                text = feed.name!!,
+                style = MaterialTheme.typography.titleMedium,
+                color = color,
+                maxLines = 1,
+                overflow = TextOverflow.Companion.Ellipsis,
+            )
+        }
+
+        if (!color.toArgb()
+                .canDisplayOnBackground(CardDefaults.cardColors().containerColor.toArgb())
+        ) {
+            ShortSpacer()
+
+            IconText(
+                icon = painterResource(R.drawable.ic_warning),
+                tint = MaterialTheme.colorScheme.error,
+                color = MaterialTheme.colorScheme.error,
+                text = stringResource(R.string.color_contrast_too_low),
+                maxLines = Int.MAX_VALUE,
+                style = MaterialTheme.typography.bodySmall,
+                spacing = MaterialTheme.spacing.shortSpacing,
+                iconSize = 16.dp
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreen.kt b/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreen.kt
new file mode 100644
index 000000000..25f9ecd46
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreen.kt
@@ -0,0 +1,195 @@
+package com.readrops.app.feeds.color
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cafe.adriel.voyager.koin.koinScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.readrops.app.R
+import com.readrops.app.util.components.AndroidScreen
+import com.readrops.app.util.components.SelectableIconText
+import com.readrops.app.util.theme.LargeSpacer
+import com.readrops.app.util.theme.ReadropsTheme
+import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.Feed
+import org.koin.core.parameter.parametersOf
+
+class FeedColorScreen(val feed: Feed) : AndroidScreen() {
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    @Composable
+    override fun Content() {
+        val context = LocalContext.current
+        val navigator = LocalNavigator.currentOrThrow
+        val screenModel = koinScreenModel { parametersOf(feed) }
+
+        val state by screenModel.state.collectAsStateWithLifecycle()
+        val snackbarHostState = remember { SnackbarHostState() }
+
+        if (state.canExit) {
+            navigator.pop()
+        }
+
+        LaunchedEffect(state.error) {
+            if (state.error != null) {
+                snackbarHostState.showSnackbar(state.error!!)
+                screenModel.resetError()
+            }
+        }
+
+        if (state.showColorPicker) {
+            ColorPickerDialog(
+                color = state.newColor ?: state.currentColor,
+                onValidate = {
+                    screenModel.setNewColor(it)
+                    screenModel.closeColorPickerDialog()
+                },
+                onDismiss = { screenModel.closeColorPickerDialog() }
+            )
+        }
+
+        Scaffold(
+            topBar = {
+                TopAppBar(
+                    title = { Text(stringResource(R.string.feed_color)) },
+                    navigationIcon = {
+                        IconButton(
+                            onClick = { navigator.pop() }
+                        ) {
+                            Icon(
+                                imageVector = Icons.AutoMirrored.Default.ArrowBack,
+                                contentDescription = null
+                            )
+                        }
+                    }
+                )
+            },
+            snackbarHost = { SnackbarHost(snackbarHostState) }
+        ) { innerPadding ->
+            Column(
+                modifier = Modifier
+                    .padding(innerPadding)
+                    .padding(vertical = MaterialTheme.spacing.shortSpacing)
+            ) {
+                Row(
+                    horizontalArrangement = Arrangement.SpaceBetween,
+                    verticalAlignment = Alignment.CenterVertically,
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    Text(
+                        text = stringResource(R.string.preview),
+                        style = MaterialTheme.typography.labelMedium,
+                        color = MaterialTheme.colorScheme.secondary,
+                        modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+                    )
+
+                    IconButton(
+                        onClick = { screenModel.resetColor() },
+                    ) {
+                        if (state.canValidate) {
+                            Icon(
+                                painter = painterResource(R.drawable.ic_undo),
+                                contentDescription = null
+                            )
+                        }
+                    }
+                }
+
+                ReadropsTheme(useDarkTheme = isSystemInDarkTheme()) {
+                    ColorPreview(
+                        feed = feed,
+                        color = state.color,
+                    )
+                }
+
+                ReadropsTheme(useDarkTheme = !isSystemInDarkTheme()) {
+                    ColorPreview(
+                        feed = feed,
+                        color = state.color,
+                    )
+                }
+
+                LargeSpacer()
+
+                Text(
+                    text = stringResource(R.string.actions),
+                    style = MaterialTheme.typography.labelMedium,
+                    color = MaterialTheme.colorScheme.secondary,
+                    modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+                )
+
+                SelectableIconText(
+                    icon = painterResource(R.drawable.ic_sync),
+                    text = stringResource(R.string.reload_color_favicon),
+                    style = MaterialTheme.typography.titleSmall,
+                    padding = MaterialTheme.spacing.mediumSpacing,
+                    spacing = MaterialTheme.spacing.mediumSpacing,
+                    iconSize = 24.dp,
+                    tint = MaterialTheme.colorScheme.primary,
+                    onClick = { screenModel.reloadColor(context) },
+                )
+
+                SelectableIconText(
+                    icon = painterResource(R.drawable.ic_reset_color),
+                    text = stringResource(R.string.use_default_color),
+                    style = MaterialTheme.typography.titleSmall,
+                    padding = MaterialTheme.spacing.mediumSpacing,
+                    spacing = MaterialTheme.spacing.mediumSpacing,
+                    iconSize = 24.dp,
+                    tint = MaterialTheme.colorScheme.primary,
+                    onClick = { screenModel.removeColor() },
+                )
+
+                SelectableIconText(
+                    icon = painterResource(R.drawable.ic_color),
+                    text = stringResource(R.string.select_color),
+                    style = MaterialTheme.typography.titleSmall,
+                    padding = MaterialTheme.spacing.mediumSpacing,
+                    spacing = MaterialTheme.spacing.mediumSpacing,
+                    iconSize = 24.dp,
+                    tint = MaterialTheme.colorScheme.primary,
+                    onClick = { screenModel.showColorPickerDialog() }
+                )
+
+                LargeSpacer()
+
+                Button(
+                    onClick = { screenModel.validate() },
+                    enabled = state.canValidate,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+                ) {
+                    Text(text = stringResource(R.string.validate))
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreenModel.kt b/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreenModel.kt
new file mode 100644
index 000000000..1aa1da94b
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/color/FeedColorScreenModel.kt
@@ -0,0 +1,103 @@
+package com.readrops.app.feeds.color
+
+import android.content.Context
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import coil3.request.allowHardware
+import coil3.toBitmap
+import com.readrops.app.R
+import com.readrops.app.util.FeedColors
+import com.readrops.app.util.extensions.getColorOrNull
+import com.readrops.db.Database
+import com.readrops.db.entities.Feed
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class FeedColorScreenModel(
+    private val feed: Feed,
+    private val database: Database,
+    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : StateScreenModel(FeedColorState(currentColor = feed.getColorOrNull())) {
+
+    fun reloadColor(context: Context) {
+        screenModelScope.launch(dispatcher) {
+            val imageLoader = context.imageLoader
+            val bitmap = imageLoader.execute(
+                ImageRequest.Builder(context)
+                    .data(feed.iconUrl)
+                    .allowHardware(false)
+                    .build()
+            ).image?.toBitmap()
+
+            if (bitmap != null) {
+                val newColor = FeedColors.getFeedColor(bitmap)
+                    .run { Color(this) }
+
+                if (newColor != state.value.currentColor) {
+                    mutableState.update { it.copy(newColor = newColor) }
+                }
+            } else {
+                mutableState.update { it.copy(error = context.getString(R.string.error_occurred_reloading_favicon_color)) }
+            }
+        }
+    }
+
+    fun showColorPickerDialog() = mutableState.update { it.copy(showColorPicker = true) }
+
+    fun closeColorPickerDialog() = mutableState.update { it.copy(showColorPicker = false) }
+
+    fun setNewColor(color: Color) =
+        mutableState.update { it.copy(newColor = color, useDefaultColor = false) }
+
+    fun removeColor() = mutableState.update {
+        it.copy(
+            newColor = null,
+            currentColor = null,
+            useDefaultColor = feed.getColorOrNull() != null
+        )
+    }
+
+    fun resetColor() =
+        mutableState.update {
+            it.copy(
+                newColor = null,
+                currentColor = feed.getColorOrNull(),
+                useDefaultColor = false
+            )
+        }
+
+    fun resetError() = mutableState.update { it.copy(error = null) }
+
+    fun validate() {
+        screenModelScope.launch {
+            database.feedDao().updateFeedColor(feed.id, state.value.newColor?.toArgb() ?: 0)
+            mutableState.update { it.copy(canExit = true) }
+        }
+    }
+
+}
+
+data class FeedColorState(
+    val currentColor: Color? = null,
+    val newColor: Color? = null,
+    val showColorPicker: Boolean = false,
+    val error: String? = null,
+    val canExit: Boolean = false,
+    val useDefaultColor: Boolean = false
+) {
+
+    val canValidate = useDefaultColor || newColor != null
+
+    val color: Color
+        @Composable
+        get() = newColor ?: currentColor ?: MaterialTheme.colorScheme.primary
+}
+
diff --git a/app/src/main/java/com/readrops/app/feeds/components/FeedBanner.kt b/app/src/main/java/com/readrops/app/feeds/components/FeedBanner.kt
new file mode 100644
index 000000000..01b8f5a5a
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/components/FeedBanner.kt
@@ -0,0 +1,108 @@
+package com.readrops.app.feeds.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import coil3.compose.AsyncImage
+import com.readrops.app.R
+import com.readrops.app.util.theme.MediumSpacer
+import com.readrops.app.util.theme.VeryShortSpacer
+import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.Feed
+
+@Composable
+fun FeedBanner(feed: Feed) {
+    Column {
+        Box(
+            modifier = Modifier.fillMaxWidth()
+        ) {
+            if (feed.imageUrl != null) {
+                AsyncImage(
+                    model = feed.imageUrl,
+                    contentDescription = null,
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier
+                        .matchParentSize()
+                        .drawWithContent {
+                            drawContent()
+                            drawRect(
+                                color = Color.Black.copy(alpha = 0.65f)
+                            )
+                        }
+                )
+            }
+
+            Row(
+                horizontalArrangement = Arrangement.Center,
+                verticalAlignment = Alignment.CenterVertically,
+                modifier = Modifier.padding(
+                    top = MaterialTheme.spacing.largeSpacing,
+                    start = MaterialTheme.spacing.largeSpacing,
+                    end = MaterialTheme.spacing.largeSpacing,
+                    bottom = MaterialTheme.spacing.mediumSpacing
+                )
+            ) {
+                AsyncImage(
+                    model = feed.iconUrl,
+                    contentDescription = feed.name!!,
+                    placeholder = painterResource(id = R.drawable.ic_rss_feed_grey),
+                    error = painterResource(id = R.drawable.ic_rss_feed_grey),
+                    modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
+                )
+
+                MediumSpacer()
+
+                Column {
+                    Text(
+                        text = feed.name!!,
+                        style = MaterialTheme.typography.titleLarge,
+                        color = if (feed.imageUrl != null) {
+                            Color.White
+                        } else {
+                            MaterialTheme.colorScheme.onBackground
+                        },
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+
+                    if (feed.description != null) {
+                        VeryShortSpacer()
+
+                        Text(
+                            text = feed.description!!,
+                            style = MaterialTheme.typography.bodyMedium,
+                            color = if (feed.imageUrl != null) {
+                                Color.White
+                            } else {
+                                MaterialTheme.colorScheme.onSurfaceVariant
+                            },
+                            maxLines = 2,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                    }
+                }
+            }
+        }
+
+        if (feed.imageUrl == null) {
+            HorizontalDivider(
+                modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/FeedItem.kt b/app/src/main/java/com/readrops/app/feeds/components/FeedItem.kt
similarity index 94%
rename from app/src/main/java/com/readrops/app/feeds/FeedItem.kt
rename to app/src/main/java/com/readrops/app/feeds/components/FeedItem.kt
index 3d96b3937..67dbf227a 100644
--- a/app/src/main/java/com/readrops/app/feeds/FeedItem.kt
+++ b/app/src/main/java/com/readrops/app/feeds/components/FeedItem.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.feeds
+package com.readrops.app.feeds.components
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.combinedClickable
@@ -64,14 +64,14 @@ fun FeedItem(
 
             FeedIcon(
                 iconUrl = feed.iconUrl,
-                name = feed.name!!,
+                name = feed.name.orEmpty(),
                 size = 16.dp
             )
 
             ShortSpacer()
 
             Text(
-                text = feed.name!!,
+                text = feed.name.orEmpty(),
                 style = MaterialTheme.typography.bodyMedium,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis
diff --git a/app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt b/app/src/main/java/com/readrops/app/feeds/components/FolderExpandableItem.kt
similarity index 96%
rename from app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt
rename to app/src/main/java/com/readrops/app/feeds/components/FolderExpandableItem.kt
index 655a59673..dc86d0794 100644
--- a/app/src/main/java/com/readrops/app/feeds/FolderExpandableItem.kt
+++ b/app/src/main/java/com/readrops/app/feeds/components/FolderExpandableItem.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.feeds
+package com.readrops.app.feeds.components
 
 import androidx.compose.animation.animateContentSize
 import androidx.compose.animation.core.LinearOutSlowInEasing
@@ -84,7 +84,7 @@ fun FolderExpandableItem(
                     MediumSpacer()
 
                     Text(
-                        text = folder.name!!,
+                        text = folder.name.orEmpty() + if (feeds.isNotEmpty()) " (${feeds.size})" else "",
                         style = MaterialTheme.typography.bodyLarge,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis,
diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt
deleted file mode 100644
index f9128494a..000000000
--- a/app/src/main/java/com/readrops/app/feeds/dialogs/AddFeedDialog.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.readrops.app.feeds.dialogs
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Clear
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import com.readrops.app.R
-import com.readrops.app.account.selection.adaptiveIconPainterResource
-import com.readrops.app.feeds.AddFeedDialogState
-import com.readrops.app.util.ErrorMessage
-import com.readrops.app.util.components.LoadingTextButton
-import com.readrops.app.util.components.dialog.BaseDialog
-import com.readrops.app.util.theme.LargeSpacer
-import com.readrops.app.util.theme.MediumSpacer
-import com.readrops.app.util.theme.ShortSpacer
-import com.readrops.db.entities.account.Account
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun AddFeedDialog(
-    state: AddFeedDialogState,
-    onValueChange: (String) -> Unit,
-    onExpandChange: (Boolean) -> Unit,
-    onAccountClick: (Account) -> Unit,
-    onValidate: () -> Unit,
-    onDismiss: () -> Unit
-) {
-    BaseDialog(
-        title = stringResource(R.string.add_feed_item),
-        icon = painterResource(id = R.drawable.ic_rss_feed_grey),
-        onDismiss = { if (!state.isLoading) onDismiss() }
-    ) {
-        OutlinedTextField(
-            value = state.url,
-            label = { Text(text = stringResource(id = R.string.url)) },
-            onValueChange = { onValueChange(it) },
-            singleLine = true,
-            trailingIcon = {
-                if (state.url.isNotEmpty()) {
-                    IconButton(
-                        onClick = { onValueChange("") }
-                    ) {
-                        Icon(
-                            imageVector = Icons.Default.Clear,
-                            contentDescription = null
-                        )
-                    }
-                }
-            },
-            isError = state.isError,
-            supportingText = { Text(state.error?.errorText().orEmpty()) }
-        )
-
-        ShortSpacer()
-
-        ExposedDropdownMenuBox(
-            expanded = state.isAccountDropDownExpanded,
-            onExpandedChange = { onExpandChange(!state.isAccountDropDownExpanded) }
-        ) {
-            ExposedDropdownMenu(
-                expanded = state.isAccountDropDownExpanded,
-                onDismissRequest = { onExpandChange(false) }
-            ) {
-                for (account in state.accounts) {
-                    DropdownMenuItem(
-                        text = { Text(text = account.accountName!!) },
-                        onClick = {
-                            onAccountClick(account)
-                        },
-                        leadingIcon = {
-                            Image(
-                                painter = adaptiveIconPainterResource(
-                                    id = account.accountType!!.iconRes
-                                ),
-                                contentDescription = null,
-                                modifier = Modifier.size(24.dp)
-                            )
-                        }
-                    )
-                }
-            }
-
-            OutlinedTextField(
-                value = state.selectedAccount.accountName!!,
-                readOnly = true,
-                onValueChange = {},
-                trailingIcon = {
-                    ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded)
-                },
-                leadingIcon = {
-                    Image(
-                        painter = adaptiveIconPainterResource(
-                            id = state.selectedAccount.accountType!!.iconRes
-                        ),
-                        contentDescription = null,
-                        modifier = Modifier.size(24.dp)
-                    )
-                },
-                modifier = Modifier.menuAnchor()
-            )
-        }
-
-        if (state.exception != null) {
-            MediumSpacer()
-
-            Text(
-                text = ErrorMessage.get(state.exception, LocalContext.current),
-                color = MaterialTheme.colorScheme.error,
-                textAlign = TextAlign.Center
-            )
-        }
-
-        LargeSpacer()
-
-        LoadingTextButton(
-            text = stringResource(id = R.string.validate),
-            isLoading = state.isLoading,
-            onClick = onValidate,
-        )
-    }
-}
diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt
index 19a9e0b58..6ea77223e 100644
--- a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt
+++ b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt
@@ -1,37 +1,35 @@
 package com.readrops.app.feeds.dialogs
 
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Create
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ModalBottomSheet
 import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.text.style.TextOverflow
-import coil.compose.AsyncImage
 import com.readrops.app.R
-import com.readrops.app.util.theme.LargeSpacer
+import com.readrops.app.feeds.components.FeedBanner
+import com.readrops.app.more.preferences.components.BasePreference
+import com.readrops.app.util.components.SwitchText
 import com.readrops.app.util.theme.MediumSpacer
-import com.readrops.app.util.theme.VeryShortSpacer
 import com.readrops.app.util.theme.spacing
 import com.readrops.db.entities.Feed
+import com.readrops.db.entities.OpenIn
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -40,61 +38,44 @@ fun FeedModalBottomSheet(
     onDismissRequest: () -> Unit,
     onOpen: () -> Unit,
     onUpdate: () -> Unit,
-    //onUpdateColor: () -> Unit,
+    onUpdateColor: () -> Unit,
+    onUpdateNotifications: (Boolean) -> Unit,
+    onOpenInClick: () -> Unit,
     onDelete: () -> Unit,
+    accountNotificationsEnabled: Boolean,
     canUpdateFeed: Boolean,
     canDeleteFeed: Boolean
 ) {
     ModalBottomSheet(
+        sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+        dragHandle = null,
         onDismissRequest = { onDismissRequest() }
     ) {
         Column {
-            Row(
-                horizontalArrangement = Arrangement.Center,
-                verticalAlignment = Alignment.CenterVertically,
-                modifier = Modifier.padding(
-                    horizontal = MaterialTheme.spacing.largeSpacing
-                )
-            ) {
-                AsyncImage(
-                    model = feed.iconUrl,
-                    contentDescription = feed.name!!,
-                    placeholder = painterResource(id = R.drawable.ic_rss_feed_grey),
-                    error = painterResource(id = R.drawable.ic_rss_feed_grey),
-                    modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
-                )
-
-                MediumSpacer()
-
-                Column {
-                    Text(
-                        text = feed.name!!,
-                        style = MaterialTheme.typography.titleLarge,
-                        maxLines = 1,
-                        overflow = TextOverflow.Ellipsis
-                    )
-
-                    if (feed.description != null) {
-                        VeryShortSpacer()
-
-                        Text(
-                            text = feed.description!!,
-                            style = MaterialTheme.typography.bodyMedium,
-                            color = MaterialTheme.colorScheme.onSurfaceVariant,
-                            maxLines = 2,
-                            overflow = TextOverflow.Ellipsis
-                        )
-                    }
-                }
-            }
-
-            MediumSpacer()
-
-            HorizontalDivider(
-                modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+            FeedBanner(feed)
+
+            SwitchText(
+                title = stringResource(R.string.enable_notifications),
+                subtitle = if (!accountNotificationsEnabled) {
+                    stringResource(R.string.account_notifications_disabled)
+                } else null,
+                isChecked = feed.isNotificationEnabled,
+                onCheckedChange = onUpdateNotifications
             )
 
-            MediumSpacer()
+            BasePreference(
+                title = stringResource(R.string.open_feed_in),
+                subtitle = if (feed.openIn == OpenIn.LOCAL_VIEW) {
+                    stringResource(R.string.local_view)
+                } else {
+                    stringResource(R.string.external_view)
+                },
+                onClick = onOpenInClick,
+                paddingValues = PaddingValues(
+                    horizontal = MaterialTheme.spacing.mediumSpacing,
+                    vertical = MaterialTheme.spacing.shortSpacing
+                )
+            )
 
             BottomSheetOption(
                 text = stringResource(R.string.open),
@@ -110,11 +91,11 @@ fun FeedModalBottomSheet(
                 )
             }
 
-            /*BottomSheetOption(
+            BottomSheetOption(
                 text = stringResource(R.string.update_color),
                 icon = ImageVector.vectorResource(R.drawable.ic_color),
                 onClick = onUpdateColor
-            )*/
+            )
 
             if (canDeleteFeed) {
                 BottomSheetOption(
@@ -125,7 +106,7 @@ fun FeedModalBottomSheet(
             }
         }
 
-        LargeSpacer()
+        MediumSpacer()
     }
 }
 
diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedDialogs.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedDialogs.kt
new file mode 100644
index 000000000..c5d0ed206
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedDialogs.kt
@@ -0,0 +1,148 @@
+package com.readrops.app.feeds.dialogs
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.readrops.app.R
+import com.readrops.app.feeds.DialogState
+import com.readrops.app.feeds.FeedScreenModel
+import com.readrops.app.feeds.FeedState
+import com.readrops.app.feeds.color.FeedColorScreen
+import com.readrops.app.more.preferences.components.RadioButtonPreferenceDialog
+import com.readrops.app.more.preferences.components.ToggleableInfo
+import com.readrops.app.util.components.dialog.TextFieldDialog
+import com.readrops.app.util.components.dialog.TwoChoicesDialog
+import com.readrops.db.entities.OpenIn
+
+@Composable
+fun FeedDialogs(state: FeedState, screenModel: FeedScreenModel) {
+    val uriHandler = LocalUriHandler.current
+    val navigator = LocalNavigator.currentOrThrow
+
+    val folderState by screenModel.folderState.collectAsStateWithLifecycle()
+
+    when (val dialog = state.dialog) {
+        is DialogState.DeleteFeed -> {
+            TwoChoicesDialog(
+                title = stringResource(R.string.delete_feed),
+                text = stringResource(R.string.delete_feed_question, dialog.feed.name!!),
+                icon = rememberVectorPainter(image = Icons.Default.Delete),
+                confirmText = stringResource(R.string.delete),
+                dismissText = stringResource(R.string.cancel),
+                onDismiss = { screenModel.closeDialog() },
+                onConfirm = {
+                    screenModel.deleteFeed(dialog.feed)
+                    screenModel.closeDialog()
+                }
+            )
+        }
+
+        is DialogState.FeedSheet -> {
+            FeedModalBottomSheet(
+                feed = dialog.feed,
+                accountNotificationsEnabled = state.isAccountNotificationsEnabled,
+                onDismissRequest = { screenModel.closeDialog() },
+                onOpen = {
+                    uriHandler.openUri(dialog.feed.siteUrl!!)
+                    screenModel.closeDialog()
+                },
+                onUpdate = {
+                    screenModel.openDialog(DialogState.UpdateFeed(dialog.feed, dialog.folder))
+                },
+                onDelete = { screenModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
+                onUpdateNotifications = {
+                    screenModel.updateFeedNotifications(dialog.feed.id, it)
+                },
+                onOpenInClick = {
+                    screenModel.openDialog(DialogState.UpdateFeedOpenInSetting(dialog.feed))
+                },
+                onUpdateColor = {
+                    navigator.push(FeedColorScreen(dialog.feed))
+                    screenModel.closeDialog(dialog)
+                },
+                canUpdateFeed = dialog.config.canUpdateFeed,
+                canDeleteFeed = dialog.config.canDeleteFeed
+            )
+        }
+
+        is DialogState.UpdateFeed -> {
+            UpdateFeedDialog(
+                viewModel = screenModel,
+                onDismissRequest = { screenModel.closeDialog(dialog) }
+            )
+        }
+
+        DialogState.AddFolder -> {
+            TextFieldDialog(
+                title = stringResource(id = R.string.add_folder),
+                icon = painterResource(id = R.drawable.ic_new_folder),
+                label = stringResource(id = R.string.name),
+                state = folderState,
+                onValueChange = { screenModel.setFolderName(it) },
+                onValidate = { screenModel.folderValidate() },
+                onDismiss = { screenModel.closeDialog(DialogState.AddFolder) }
+            )
+        }
+
+        is DialogState.DeleteFolder -> {
+            TwoChoicesDialog(
+                title = stringResource(R.string.delete_folder),
+                text = if (state.config?.showCustomFolderDeleteMessage == true) {
+                    stringResource(R.string.freshrss_delete_folder_question, dialog.folder.name!!)
+                } else {
+                    stringResource(R.string.delete_folder_question, dialog.folder.name!!)
+                },
+                icon = rememberVectorPainter(image = Icons.Default.Delete),
+                confirmText = stringResource(R.string.delete),
+                dismissText = stringResource(R.string.cancel),
+                onDismiss = { screenModel.closeDialog() },
+                onConfirm = {
+                    screenModel.deleteFolder(dialog.folder)
+                    screenModel.closeDialog()
+                }
+            )
+        }
+
+        is DialogState.UpdateFolder -> {
+            TextFieldDialog(
+                title = stringResource(id = R.string.edit_folder),
+                icon = painterResource(id = R.drawable.ic_folder_grey),
+                label = stringResource(id = R.string.name),
+                state = folderState,
+                onValueChange = { screenModel.setFolderName(it) },
+                onValidate = { screenModel.folderValidate(updateFolder = true) },
+                onDismiss = { screenModel.closeDialog(DialogState.UpdateFolder(dialog.folder)) }
+            )
+        }
+
+        is DialogState.UpdateFeedOpenInSetting -> {
+            RadioButtonPreferenceDialog(
+                title = stringResource(R.string.open_feed_in),
+                entries = listOf(
+                    ToggleableInfo(
+                        key = OpenIn.LOCAL_VIEW,
+                        text = stringResource(R.string.local_view),
+                        isSelected = dialog.feed.openIn == OpenIn.LOCAL_VIEW
+                    ),
+                    ToggleableInfo(
+                        key = OpenIn.EXTERNAL_VIEW,
+                        text = stringResource(R.string.external_view),
+                        isSelected = dialog.feed.openIn == OpenIn.EXTERNAL_VIEW
+                    )
+                ),
+                onCheckChange = { screenModel.updateFeedOpenInSetting(dialog.feed.id, it) },
+                onDismiss = { screenModel.closeDialog(dialog) },
+            )
+        }
+
+        null -> {}
+    }
+}
diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt
index e2dc7d3ed..09673bb0e 100644
--- a/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt
+++ b/app/src/main/java/com/readrops/app/feeds/dialogs/UpdateFeedDialog.kt
@@ -6,18 +6,17 @@ import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.readrops.app.R
 import com.readrops.app.feeds.FeedScreenModel
-import com.readrops.app.util.ErrorMessage
 import com.readrops.app.util.components.LoadingTextButton
 import com.readrops.app.util.components.dialog.BaseDialog
 import com.readrops.app.util.theme.LargeSpacer
@@ -118,15 +117,15 @@ fun UpdateFeedDialog(
                         )
                     }
                 },
-                modifier = Modifier.menuAnchor()
+                modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
             )
         }
 
-        if (state.exception != null) {
+        if (state.error != null) {
             MediumSpacer()
 
             Text(
-                text = ErrorMessage.get(state.exception!!, LocalContext.current),
+                text = state.error!!,
                 color = MaterialTheme.colorScheme.error
             )
         }
diff --git a/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreen.kt b/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreen.kt
new file mode 100644
index 000000000..0671ce4b8
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreen.kt
@@ -0,0 +1,247 @@
+package com.readrops.app.feeds.newfeed
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cafe.adriel.voyager.koin.koinScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.readrops.app.R
+import com.readrops.app.account.selection.adaptiveIconPainterResource
+import com.readrops.app.util.components.AndroidScreen
+import com.readrops.app.util.components.DropdownBox
+import com.readrops.app.util.components.DropdownBoxValue
+import com.readrops.app.util.components.LoadingButton
+import com.readrops.app.util.components.TextHorizontalDivider
+import com.readrops.app.util.theme.LargeSpacer
+import com.readrops.app.util.theme.MediumSpacer
+import com.readrops.app.util.theme.ShortSpacer
+import com.readrops.app.util.theme.spacing
+import org.koin.core.parameter.parametersOf
+
+class NewFeedScreen(val url: String? = null) : AndroidScreen() {
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    @Composable
+    override fun Content() {
+        val navigator = LocalNavigator.currentOrThrow
+        val screenModel = koinScreenModel { parametersOf(url) }
+
+        val appBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+        val state by screenModel.state.collectAsStateWithLifecycle()
+
+        if (state.popScreen) {
+            navigator.pop()
+        }
+
+        Scaffold(
+            topBar = {
+                TopAppBar(
+                    title = {
+                        Text(text = stringResource(R.string.add_feed))
+                    },
+                    navigationIcon = {
+                        IconButton(
+                            onClick = { navigator.pop() }
+                        ) {
+                            Icon(
+                                imageVector = Icons.AutoMirrored.Default.ArrowBack,
+                                contentDescription = null
+                            )
+                        }
+                    },
+                    scrollBehavior = appBarScrollBehavior
+                )
+            }
+        ) { paddingValues ->
+            Column(
+                modifier = Modifier
+                    .padding(paddingValues)
+                    .padding(horizontal = MaterialTheme.spacing.mediumSpacing)
+                    .nestedScroll(appBarScrollBehavior.nestedScrollConnection)
+                    .verticalScroll(rememberScrollState())
+                    .animateContentSize()
+                    .fillMaxSize(),
+            ) {
+                OutlinedTextField(
+                    value = state.actualUrl,
+                    label = { Text(text = stringResource(R.string.enter_url)) },
+                    onValueChange = { screenModel.updateUrl(it) },
+                    singleLine = true,
+                    trailingIcon = {
+                        if (state.actualUrl.isNotEmpty()) {
+                            IconButton(
+                                onClick = { screenModel.updateUrl("") }
+                            ) {
+                                Icon(
+                                    imageVector = Icons.Default.Clear,
+                                    contentDescription = null
+                                )
+                            }
+                        }
+                    },
+                    keyboardOptions = KeyboardOptions(
+                        keyboardType = KeyboardType.Uri,
+                        imeAction = ImeAction.Done
+                    ),
+                    keyboardActions = KeyboardActions(onDone = {
+                        screenModel.validate()
+                    }),
+                    isError = state.isURLError,
+                    supportingText = { Text(state.urlError?.errorText().orEmpty()) },
+                    modifier = Modifier.fillMaxWidth()
+                )
+
+                ShortSpacer()
+
+                TextHorizontalDivider(text = stringResource(R.string.account))
+
+                ShortSpacer()
+
+                DropdownBox(
+                    expanded = state.isAccountDropdownExpanded,
+                    text = state.selectedAccount?.name.orEmpty(),
+                    label = stringResource(R.string.choose_account),
+                    painter = if (state.selectedAccount != null) {
+                        adaptiveIconPainterResource(state.selectedAccount!!.type!!.iconRes)
+                    } else null,
+                    values = state.accounts.map {
+                        DropdownBoxValue(
+                            id = it.id,
+                            text = it.name.orEmpty(),
+                            painter = adaptiveIconPainterResource(it.type!!.iconRes)
+                        )
+                    },
+                    onExpandedChange = { screenModel.updateAccountDropDownExpandStatus(it) },
+                    onValueClick = { id -> screenModel.updateSelectedAccount(state.accounts.first { it.id == id }) },
+                    onDismiss = { screenModel.updateAccountDropDownExpandStatus(false) },
+                    modifier = Modifier.fillMaxWidth()
+                )
+
+                ShortSpacer()
+
+                DropdownBox(
+                    expanded = state.isFoldersDropdownExpanded,
+                    text = state.selectedFolder?.name.orEmpty(),
+                    label = stringResource(R.string.choose_folder),
+                    painter = if (state.selectedFolder != null) {
+                        painterResource(R.drawable.ic_folder_grey)
+                    } else null,
+                    enabled = state.folders.isNotEmpty(),
+                    values = state.folders.map {
+                        DropdownBoxValue(
+                            id = it.id,
+                            text = it.name.orEmpty(),
+                            painter = painterResource(R.drawable.ic_folder_grey)
+                        )
+                    },
+                    onExpandedChange = { screenModel.updateFolderDropdownExpandStatus(it) },
+                    onValueClick = { id -> screenModel.updateSelectedFolder(state.folders.first { it.id == id }) },
+                    onDismiss = { screenModel.updateFolderDropdownExpandStatus(false) },
+                    modifier = Modifier.fillMaxWidth()
+                )
+
+                if (state.parsingResults.isNotEmpty()) {
+                    LargeSpacer()
+
+                    TextHorizontalDivider(
+                        text = stringResource(R.string.feeds) + " " + stringResource(
+                            R.string.selected,
+                            state.selectedResultsCount
+                        )
+                    )
+
+                    ShortSpacer()
+
+                    for (parsingResult in state.parsingResults) {
+                        ParsingResultItem(
+                            parsingResult = parsingResult,
+                            folders = state.folders,
+                            onExpandedChange = {
+                                screenModel.updateParsingResultExpandedState(
+                                    parsingResult = parsingResult,
+                                    isExpanded = it
+                                )
+                            },
+                            onSelectFolder = { folder ->
+                                screenModel.updateParsingResultFolder(
+                                    parsingResult = parsingResult,
+                                    folder = folder
+                                )
+                            },
+                            onCheckedChange = {
+                                screenModel.updateParsingResultCheckedState(
+                                    parsingResult
+                                )
+                            },
+                            onDismiss = {
+                                screenModel.updateParsingResultExpandedState(
+                                    parsingResult = parsingResult,
+                                    isExpanded = false
+                                )
+                            },
+                            error = parsingResult.error,
+                            modifier = Modifier.fillMaxWidth()
+                        )
+
+                        ShortSpacer()
+                    }
+                }
+
+                if (state.error != null) {
+                    MediumSpacer()
+
+                    Text(
+                        text = state.error!!,
+                        style = MaterialTheme.typography.bodyMedium,
+                        color = MaterialTheme.colorScheme.error,
+                        textAlign = TextAlign.Center,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+
+                MediumSpacer()
+
+                LoadingButton(
+                    text = if (state.selectedResultsCount > 0) {
+                        stringResource(R.string.add_selected_feeds, state.selectedResultsCount)
+                    } else {
+                        stringResource(id = R.string.validate)
+                    },
+                    isLoading = state.isLoading,
+                    onClick = { screenModel.validate() },
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreenModel.kt b/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreenModel.kt
new file mode 100644
index 000000000..2d8516fe8
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/newfeed/NewFeedScreenModel.kt
@@ -0,0 +1,370 @@
+package com.readrops.app.feeds.newfeed
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Patterns
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import com.readrops.api.localfeed.LocalRSSDataSource
+import com.readrops.api.services.Credentials
+import com.readrops.api.utils.ApiUtils
+import com.readrops.api.utils.AuthInterceptor
+import com.readrops.api.utils.HtmlParser
+import com.readrops.app.R
+import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.util.accounterror.AccountError
+import com.readrops.app.util.components.TextFieldError
+import com.readrops.app.util.extensions.isConnected
+import com.readrops.db.Database
+import com.readrops.db.entities.Feed
+import com.readrops.db.entities.Folder
+import com.readrops.db.entities.account.Account
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import org.koin.core.parameter.parametersOf
+
+class NewFeedScreenModel(
+    private val database: Database,
+    private val dataSource: LocalRSSDataSource,
+    private val context: Context,
+    url: String?,
+    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : StateScreenModel(State(url = url.orEmpty())), KoinComponent {
+
+    private val selectedAccountState = MutableStateFlow(state.value.selectedAccount)
+
+    private lateinit var accountError: AccountError
+
+    init {
+        screenModelScope.launch(dispatcher) {
+            database.accountDao()
+                .selectAllAccounts()
+                .map { it.filter { account -> account.config.canCreateFeed } }
+                .collect { accounts ->
+                    val selectedAccount = accounts.find { it.isCurrentAccount }
+                        ?: accounts.first()
+
+                    accountError = AccountError.from(selectedAccount, context)
+                    selectedAccountState.update { selectedAccount }
+
+                    mutableState.update { newFeedState ->
+                        newFeedState.copy(
+                            accounts = accounts,
+                            selectedAccount = selectedAccount
+                        )
+                    }
+                }
+        }
+
+        screenModelScope.launch(dispatcher) {
+            selectedAccountState.collect { selectedAccount ->
+                if (selectedAccount != null) {
+                    val folders = if (selectedAccount.config.addNoFolder) {
+                        database.folderDao().selectFolders(selectedAccount.id).first() +
+                                Folder(name = context.resources.getString(R.string.no_folder))
+                    } else {
+                        database.folderDao().selectFolders(selectedAccount.id).first()
+                    }.sortedWith(
+                        compareByDescending { it.name?.startsWith("_") }
+                            .then(Folder::compareTo)
+                    )
+
+                    val newParsingResults = mutableState.value.parsingResults.map {
+                        it.copy(folder = folders.firstOrNull())
+                    }
+
+                    mutableState.update {
+                        it.copy(
+                            folders = folders,
+                            selectedFolder = folders.firstOrNull(),
+                            parsingResults = newParsingResults
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun validate() {
+        if (!context.isConnected()) {
+            mutableState.update { it.copy(error = context.getString(R.string.no_network)) }
+            return
+        }
+
+        val url = mutableState.value.actualUrl
+
+        if (state.value.selectedResultsCount > 0) {
+            mutableState.update {
+                it.copy(
+                    error = null,
+                    isLoading = true,
+                    parsingResults = state.value.parsingResults.map { parsingResult ->
+                        parsingResult.copy(error = null)
+                    }
+                )
+            }
+
+            screenModelScope.launch(dispatcher) {
+                insertFeeds(state.value.parsingResults
+                    .filter { it.isSelected }
+                    .map {
+                        Feed(
+                            url = it.url,
+                            folderId = it.folderId,
+                            remoteFolderId = it.folder?.remoteId
+                        )
+                    })
+            }
+        } else {
+            when {
+                url.isEmpty() -> {
+                    mutableState.update {
+                        it.copy(urlError = TextFieldError.EmptyField)
+                    }
+                    return
+                }
+
+                !Patterns.WEB_URL.matcher(url).matches() -> {
+                    mutableState.update {
+                        it.copy(urlError = TextFieldError.BadUrl)
+                    }
+                    return
+                }
+
+                else -> loadFeeds()
+            }
+        }
+    }
+
+    private fun loadFeeds() {
+        screenModelScope.launch(dispatcher) {
+            mutableState.update { it.copy(error = null, isLoading = true) }
+            val url = state.value.actualUrl
+
+            try {
+                if (dataSource.isUrlRSSResource(url)) {
+                    insertFeeds(
+                        listOf(
+                            Feed(
+                                url = url,
+                                folderId = state.value.folderId,
+                                remoteFolderId = state.value.selectedFolder?.remoteId
+                            )
+                        )
+                    )
+                } else {
+                    val rssUrls = HtmlParser.getFeedLink(url, get())
+
+                    when {
+                        rssUrls.isEmpty() -> mutableState.update {
+                            it.copy(urlError = TextFieldError.NoRSSFeed, isLoading = false)
+                        }
+
+                        rssUrls.size == 1 -> insertFeeds(
+                            listOf(
+                                Feed(
+                                    url = rssUrls.first().url,
+                                    folderId = state.value.folderId,
+                                    remoteFolderId = state.value.selectedFolder?.remoteId
+                                )
+                            )
+                        )
+
+                        else -> {
+                            val parsingResults = rssUrls.map {
+                                ParsingResultState(
+                                    url = it.url,
+                                    label = it.label,
+                                    isSelected = true,
+                                    folder = state.value.selectedFolder,
+                                    isExpanded = false
+                                )
+                            }
+
+                            mutableState.update {
+                                it.copy(
+                                    parsingResults = parsingResults,
+                                    isLoading = false
+                                )
+                            }
+                        }
+                    }
+                }
+            } catch (e: Exception) {
+                mutableState.update {
+                    it.copy(
+                        error = accountError.newFeedMessage(e),
+                        isLoading = false
+                    )
+                }
+            }
+        }
+    }
+
+    private suspend fun insertFeeds(feeds: List) {
+        val selectedAccount = mutableState.value.selectedAccount
+
+        if (selectedAccount != null && !selectedAccount.isLocal) {
+            get().apply {
+                selectedAccount.login = getString(selectedAccount.loginKey, null)
+                selectedAccount.password = getString(selectedAccount.passwordKey, null)
+            }
+
+            get().apply {
+                credentials = Credentials.toCredentials(selectedAccount)
+            }
+        }
+
+        val repository = get { parametersOf(selectedAccount) }
+
+        val errors = repository.insertNewFeeds(
+            newFeeds = feeds,
+            onUpdate = {}
+        )
+
+        if (errors.isEmpty()) {
+            mutableState.update { it.copy(popScreen = true) }
+        } else {
+            if (state.value.selectedResultsCount > 0) {
+                val newParsingResults = state.value.parsingResults.map { parsingResult ->
+                    val feed = errors.keys.find { feed -> feed.url == parsingResult.url }
+
+                    if (feed != null) {
+                        val error = errors[feed]
+                        parsingResult.copy(error = accountError.newFeedMessage(error!!))
+                    } else {
+                        parsingResult
+                    }
+                }
+
+                mutableState.update {
+                    it.copy(
+                        parsingResults = newParsingResults,
+                        isLoading = false
+                    )
+                }
+            } else {
+                mutableState.update {
+                    it.copy(
+                        error = accountError.newFeedMessage(errors.values.first()),
+                        isLoading = false
+                    )
+                }
+            }
+        }
+    }
+
+    fun updateUrl(url: String) = mutableState.update { it.copy(url = url, urlError = null) }
+
+    fun updateAccountDropDownExpandStatus(isExpanded: Boolean) =
+        mutableState.update { it.copy(isAccountDropdownExpanded = isExpanded) }
+
+    fun updateSelectedAccount(account: Account) {
+        mutableState.update {
+            it.copy(
+                selectedAccount = account,
+                isAccountDropdownExpanded = false
+            )
+        }
+
+        selectedAccountState.update { account }
+    }
+
+    fun updateFolderDropdownExpandStatus(isExpanded: Boolean) =
+        mutableState.update { it.copy(isFoldersDropdownExpanded = isExpanded) }
+
+    fun updateSelectedFolder(folder: Folder) {
+        val newParsingResults = mutableState.value.parsingResults.map {
+            it.copy(folder = folder)
+        }
+
+        mutableState.update {
+            it.copy(
+                selectedFolder = folder,
+                isFoldersDropdownExpanded = false,
+                parsingResults = newParsingResults
+            )
+        }
+    }
+
+    fun updateParsingResultCheckedState(parsingResult: ParsingResultState) {
+        val newList = mutableState.value.parsingResults.map {
+            if (it == parsingResult) {
+                parsingResult.copy(isSelected = !parsingResult.isSelected)
+            } else {
+                it
+            }
+        }
+
+        mutableState.update { it.copy(parsingResults = newList) }
+    }
+
+    fun updateParsingResultExpandedState(parsingResult: ParsingResultState, isExpanded: Boolean) {
+        val newList = mutableState.value.parsingResults.map {
+            if (it == parsingResult) {
+                parsingResult.copy(isExpanded = isExpanded)
+            } else {
+                it
+            }
+        }
+
+        mutableState.update { it.copy(parsingResults = newList) }
+    }
+
+    fun updateParsingResultFolder(parsingResult: ParsingResultState, folder: Folder) {
+        val newList = mutableState.value.parsingResults.map {
+            if (it == parsingResult) {
+                parsingResult.copy(folder = folder, isExpanded = false)
+            } else {
+                it
+            }
+        }
+
+        mutableState.update { it.copy(parsingResults = newList) }
+    }
+}
+
+data class State(
+    private val url: String = "",
+    val selectedAccount: Account? = null,
+    val selectedFolder: Folder? = null,
+    val accounts: List = listOf(),
+    val folders: List = listOf(),
+    val isAccountDropdownExpanded: Boolean = false,
+    val isFoldersDropdownExpanded: Boolean = false,
+    val urlError: TextFieldError? = null,
+    val error: String? = null,
+    val isLoading: Boolean = false,
+    val popScreen: Boolean = false,
+    val parsingResults: List = listOf()
+) {
+    val isURLError: Boolean get() = urlError != null
+
+    val selectedResultsCount: Int get() = parsingResults.count { it.isSelected }
+
+    val folderId: Int? get() = selectedFolder?.id.takeUnless { it == 0 }
+
+    /**
+     * Handles known special cases where RSS source can not be deduced via standard methods but
+     * methods to deduce it is known. Currently used to deduce RSS feeds from Youtube playlists
+     */
+    val actualUrl: String get() = ApiUtils.handleRssSpecialCases(url)
+}
+
+data class ParsingResultState(
+    val url: String,
+    val label: String?,
+    val isSelected: Boolean,
+    val folder: Folder?,
+    val isExpanded: Boolean,
+    val error: String? = null
+) {
+    val folderId: Int? get() = folder?.id.takeUnless { it == 0 }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/feeds/newfeed/ParsingResultItem.kt b/app/src/main/java/com/readrops/app/feeds/newfeed/ParsingResultItem.kt
new file mode 100644
index 000000000..c9f400b28
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/feeds/newfeed/ParsingResultItem.kt
@@ -0,0 +1,131 @@
+package com.readrops.app.feeds.newfeed
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ShapeDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import com.readrops.app.R
+import com.readrops.app.util.components.CompactDropdownBox
+import com.readrops.app.util.components.DropdownBoxValue
+import com.readrops.app.util.components.IconText
+import com.readrops.app.util.theme.ShortSpacer
+import com.readrops.app.util.theme.VeryShortSpacer
+import com.readrops.app.util.theme.spacing
+import com.readrops.db.entities.Folder
+
+@Composable
+fun ParsingResultItem(
+    parsingResult: ParsingResultState,
+    folders: List,
+    onCheckedChange: (Boolean) -> Unit,
+    onExpandedChange: (Boolean) -> Unit,
+    onSelectFolder: (Folder) -> Unit,
+    onDismiss: () -> Unit,
+    modifier: Modifier = Modifier,
+    error: String? = null
+) {
+    Surface(
+        color = animateColorAsState(
+            targetValue = if (parsingResult.isSelected) {
+                MaterialTheme.colorScheme.primaryContainer
+            } else {
+                MaterialTheme.colorScheme.surface
+            },
+            animationSpec = spring(stiffness = Spring.StiffnessHigh),
+            label = "ParsingResult item color animation"
+        ).value,
+        shape = ShapeDefaults.Medium,
+        onClick = { onCheckedChange(!parsingResult.isSelected) },
+        modifier = modifier
+    ) {
+        Row(
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            ShortSpacer()
+
+            Icon(
+                painter = painterResource(R.drawable.ic_rss_feed_grey),
+                tint = MaterialTheme.colorScheme.primary,
+                contentDescription = null
+            )
+
+            Column(
+                modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
+            ) {
+                Text(
+                    text = parsingResult.label ?: parsingResult.url,
+                    style = MaterialTheme.typography.labelLarge,
+                    color = MaterialTheme.colorScheme.onPrimaryContainer,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis
+                )
+
+                if (parsingResult.label != null) {
+                    VeryShortSpacer()
+
+                    Text(
+                        text = parsingResult.url,
+                        style = MaterialTheme.typography.bodySmall,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+
+                if (folders.isNotEmpty()) {
+                    ShortSpacer()
+
+                    Row(
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Icon(
+                            painter = painterResource(R.drawable.ic_folder_grey),
+                            contentDescription = null,
+                        )
+
+                        ShortSpacer()
+
+                        CompactDropdownBox(
+                            expanded = parsingResult.isExpanded,
+                            text = parsingResult.folder?.name.orEmpty(),
+                            values = folders.map {
+                                DropdownBoxValue(
+                                    id = it.id,
+                                    text = it.name.orEmpty(),
+                                    painter = painterResource(R.drawable.ic_folder_grey)
+                                )
+                            },
+                            onExpandedChange = onExpandedChange,
+                            onValueClick = { id -> onSelectFolder(folders.first { it.id == id }) },
+                            onDismiss = onDismiss,
+                        )
+                    }
+                }
+
+                if (error != null) {
+                    ShortSpacer()
+
+                    IconText(
+                        icon = painterResource(R.drawable.ic_error),
+                        text = error,
+                        style = MaterialTheme.typography.bodySmall,
+                        color = MaterialTheme.colorScheme.error,
+                        tint = MaterialTheme.colorScheme.error,
+                        maxLines = 2
+                    )
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/home/HomeScreen.kt b/app/src/main/java/com/readrops/app/home/HomeScreen.kt
index df6cf2fd6..72b349fa1 100644
--- a/app/src/main/java/com/readrops/app/home/HomeScreen.kt
+++ b/app/src/main/java/com/readrops/app/home/HomeScreen.kt
@@ -2,21 +2,24 @@ package com.readrops.app.home
 
 import androidx.activity.compose.BackHandler
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.WindowInsetsSides
 import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.only
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.AccountCircle
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.BottomAppBarDefaults
 import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Scaffold
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.ScaffoldDefaults
 import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuite
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
@@ -28,11 +31,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
 import cafe.adriel.voyager.navigator.tab.CurrentTab
 import cafe.adriel.voyager.navigator.tab.Tab
 import cafe.adriel.voyager.navigator.tab.TabNavigator
-import com.readrops.app.R
-import com.readrops.app.account.AccountTab
 import com.readrops.app.feeds.FeedTab
-import com.readrops.app.item.ItemScreen
-import com.readrops.app.more.MoreTab
 import com.readrops.app.timelime.TimelineTab
 import com.readrops.app.util.components.AndroidScreen
 import kotlinx.coroutines.channels.Channel
@@ -40,7 +39,6 @@ import kotlinx.coroutines.flow.receiveAsFlow
 
 object HomeScreen : AndroidScreen() {
 
-    private val itemChannel = Channel()
     private val tabChannel = Channel()
 
     @Composable
@@ -48,71 +46,61 @@ object HomeScreen : AndroidScreen() {
         val navigator = LocalNavigator.currentOrThrow
         val scaffoldInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
 
-        LaunchedEffect(Unit) {
-            itemChannel.receiveAsFlow()
-                .collect {
-                    navigator.push(ItemScreen(it))
-                }
-        }
+        val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
+            adaptiveInfo = currentWindowAdaptiveInfo()
+        )
 
         TabNavigator(
             tab = TimelineTab
         ) { tabNavigator ->
             CompositionLocalProvider(LocalNavigator provides navigator) {
-                Scaffold(
-                    bottomBar = {
-                        BottomAppBar {
-                            NavigationBarItem(
-                                selected = tabNavigator.current.key == TimelineTab.key,
-                                onClick = { tabNavigator.current = TimelineTab },
-                                icon = {
-                                    Icon(
-                                        painter = painterResource(R.drawable.ic_timeline),
-                                        contentDescription = null
-                                    )
-                                },
-                                label = { Text(stringResource(id = R.string.timeline)) }
-                            )
-
-                            NavigationBarItem(
-                                selected = tabNavigator.current.key == FeedTab.key,
-                                onClick = { tabNavigator.current = FeedTab },
-                                icon = {
-                                    Icon(
-                                        painter = painterResource(R.drawable.ic_rss_feed_grey),
-                                        contentDescription = null
-                                    )
-                                },
-                                label = { Text(text = stringResource(R.string.feeds)) }
-                            )
+                NavigationSuiteScaffoldLayout(
+                    layoutType = layoutType,
+                    navigationSuite =  {
+                        if (layoutType == NavigationSuiteType.NavigationRail) {
+                            NavigationRail(
+                                windowInsets = scaffoldInsets,
+                                containerColor = BottomAppBarDefaults.containerColor
+                            ) {
+                                Spacer(Modifier.weight(1f))
 
-                            NavigationBarItem(
-                                selected = tabNavigator.current.key == AccountTab.key,
-                                onClick = { tabNavigator.current = AccountTab },
-                                icon = {
-                                    Icon(
-                                        imageVector = Icons.Default.AccountCircle,
-                                        contentDescription = null,
+                                HomeTabs.entries.forEach {
+                                    NavigationRailItem(
+                                        selected = tabNavigator.current.key == it.tab.key,
+                                        onClick = { tabNavigator.current = it.tab },
+                                        icon = {
+                                            Icon(
+                                                painter = painterResource(it.iconRes),
+                                                contentDescription = null
+                                            )
+                                        },
+                                        label = { Text(stringResource(it.labelRes)) }
                                     )
-                                },
-                                label = { Text(text = stringResource(R.string.account)) }
-                            )
+                                }
 
-                            NavigationBarItem(
-                                selected = tabNavigator.current.key == MoreTab.key,
-                                onClick = { tabNavigator.current = MoreTab },
-                                icon = {
-                                    Icon(
-                                        imageVector = Icons.Default.MoreVert,
-                                        contentDescription = null,
+                                Spacer(Modifier.weight(1f))
+                            }
+                        } else {
+                            NavigationSuite(
+                                layoutType = layoutType,
+                            ) {
+                                HomeTabs.entries.forEach {
+                                    item(
+                                        selected = tabNavigator.current.key == it.tab.key,
+                                        onClick = { tabNavigator.current = it.tab },
+                                        icon = {
+                                            Icon(
+                                                painter = painterResource(it.iconRes),
+                                                contentDescription = null
+                                            )
+                                        },
+                                        label = { Text(stringResource(it.labelRes)) }
                                     )
-                                },
-                                label = { Text(stringResource(id = R.string.more)) }
-                            )
+                                }
+                            }
                         }
-                    },
-                    contentWindowInsets = scaffoldInsets
-                ) { paddingValues ->
+                    }
+                ) {
                     LaunchedEffect(Unit) {
                         tabChannel.receiveAsFlow()
                             .collect {
@@ -126,10 +114,14 @@ object HomeScreen : AndroidScreen() {
                     )
 
                     Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .padding(paddingValues)
-                            .consumeWindowInsets(paddingValues)
+                        modifier = Modifier.fillMaxSize().run {
+                            // Navigation bar already applies bottom inset, so make sure that tabs don't apply it too
+                            if (layoutType == NavigationSuiteType.NavigationBar) {
+                                consumeWindowInsets(ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Bottom))
+                            } else {
+                                this
+                            }
+                        }
                     ) {
                         CurrentTab()
                     }
@@ -138,8 +130,9 @@ object HomeScreen : AndroidScreen() {
         }
     }
 
-    suspend fun openItemScreen(itemId: Int) {
-        itemChannel.send(itemId)
+    suspend fun openItem(itemId: Int) {
+        tabChannel.send(TimelineTab)
+        TimelineTab.openItem(itemId)
     }
 
     suspend fun openTab(tab: Tab) {
diff --git a/app/src/main/java/com/readrops/app/home/HomeTabs.kt b/app/src/main/java/com/readrops/app/home/HomeTabs.kt
new file mode 100644
index 000000000..af02ea080
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/home/HomeTabs.kt
@@ -0,0 +1,21 @@
+package com.readrops.app.home
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import cafe.adriel.voyager.navigator.tab.Tab
+import com.readrops.app.R
+import com.readrops.app.account.AccountTab
+import com.readrops.app.feeds.FeedTab
+import com.readrops.app.more.MoreTab
+import com.readrops.app.timelime.TimelineTab
+
+enum class HomeTabs(
+    val tab: Tab,
+    @StringRes val labelRes: Int,
+    @DrawableRes val iconRes: Int,
+) {
+    TIMELINE(TimelineTab, R.string.timeline, R.drawable.ic_timeline),
+    FEEDS(FeedTab, R.string.feeds, R.drawable.ic_rss_feed_grey),
+    ACCOUNT(AccountTab, R.string.account, R.drawable.ic_account),
+    MORE(MoreTab, R.string.more, R.drawable.ic_more_vert)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/base/TabScreenModel.kt b/app/src/main/java/com/readrops/app/home/TabScreenModel.kt
similarity index 88%
rename from app/src/main/java/com/readrops/app/base/TabScreenModel.kt
rename to app/src/main/java/com/readrops/app/home/TabScreenModel.kt
index a517a93a4..5a677ed7c 100644
--- a/app/src/main/java/com/readrops/app/base/TabScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/home/TabScreenModel.kt
@@ -1,11 +1,13 @@
-package com.readrops.app.base
+package com.readrops.app.home
 
+import android.content.Context
 import android.content.SharedPreferences
 import cafe.adriel.voyager.core.model.ScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
 import com.readrops.api.services.Credentials
 import com.readrops.api.utils.AuthInterceptor
 import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.util.accounterror.AccountError
 import com.readrops.db.Database
 import com.readrops.db.entities.account.Account
 import kotlinx.coroutines.CoroutineDispatcher
@@ -20,10 +22,11 @@ import org.koin.core.component.get
 import org.koin.core.parameter.parametersOf
 
 /**
- * Custom ViewModel for Tab screens handling account change
+ * Custom screenModel for Tab screens handling account change
  */
 abstract class TabScreenModel(
     private val database: Database,
+    private val context: Context,
     dispatcher: CoroutineDispatcher = Dispatchers.IO
 ) : ScreenModel, KoinComponent {
 
@@ -34,6 +37,8 @@ abstract class TabScreenModel(
 
     protected var currentAccount: Account? = null
 
+    protected var accountError: AccountError? = null
+
     private val _accountEvent = MutableSharedFlow()
     protected val accountEvent =
         _accountEvent.shareIn(scope = screenModelScope, started = SharingStarted.Eagerly)
@@ -61,7 +66,7 @@ abstract class TabScreenModel(
 
                         currentAccount = account
                         repository = get(parameters = { parametersOf(account) })
-
+                        accountError = AccountError.from(account, context)
 
                         _accountEvent.emit(account)
                     }
diff --git a/app/src/main/java/com/readrops/app/item/ItemScreen.kt b/app/src/main/java/com/readrops/app/item/ItemScreen.kt
index a04505bbf..a0b13b32b 100644
--- a/app/src/main/java/com/readrops/app/item/ItemScreen.kt
+++ b/app/src/main/java/com/readrops/app/item/ItemScreen.kt
@@ -1,117 +1,54 @@
 package com.readrops.app.item
 
-import android.content.Intent
-import android.net.Uri
-import android.widget.RelativeLayout
-import androidx.browser.customtabs.CustomTabColorSchemeParams
-import androidx.browser.customtabs.CustomTabsIntent
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.geometry.Offset
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-import androidx.compose.ui.input.nestedscroll.NestedScrollSource
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.net.toUri
-import androidx.core.view.children
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cafe.adriel.voyager.koin.getScreenModel
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemKey
+import cafe.adriel.voyager.koin.koinScreenModel
 import cafe.adriel.voyager.navigator.LocalNavigator
 import cafe.adriel.voyager.navigator.currentOrThrow
-import coil.compose.AsyncImage
 import com.readrops.app.R
-import com.readrops.app.item.view.ItemNestedScrollView
-import com.readrops.app.item.view.ItemWebView
 import com.readrops.app.util.components.AndroidScreen
 import com.readrops.app.util.components.CenteredProgressIndicator
-import com.readrops.app.util.components.FeedIcon
-import com.readrops.app.util.components.IconText
-import com.readrops.app.util.theme.MediumSpacer
-import com.readrops.app.util.theme.ShortSpacer
-import com.readrops.app.util.theme.spacing
-import com.readrops.db.pojo.ItemWithFeed
-import com.readrops.db.util.DateUtils
+import com.readrops.app.util.components.Placeholder
+import com.readrops.app.util.extensions.isError
+import com.readrops.app.util.extensions.isLoading
+import com.readrops.app.util.extensions.isNotEmpty
+import com.readrops.app.util.extensions.openInCustomTab
+import com.readrops.app.util.extensions.openUrl
+import com.readrops.db.filters.QueryFilters
+import kotlinx.coroutines.flow.distinctUntilChanged
 import org.koin.core.parameter.parametersOf
-import kotlin.math.roundToInt
 
 class ItemScreen(
-    private val itemId: Int
+    private val itemId: Int,
+    private val itemIndex: Int,
+    private val queryFilters: QueryFilters
 ) : AndroidScreen() {
 
     @Composable
     override fun Content() {
         val context = LocalContext.current
-        val density = LocalDensity.current
         val navigator = LocalNavigator.currentOrThrow
 
         val screenModel =
-            getScreenModel(parameters = { parametersOf(itemId) })
+            koinScreenModel(parameters = { parametersOf(itemId, itemIndex, queryFilters) })
         val state by screenModel.state.collectAsStateWithLifecycle()
-
-        val primaryColor = MaterialTheme.colorScheme.primary
-        val backgroundColor = MaterialTheme.colorScheme.background
-        val onBackgroundColor = MaterialTheme.colorScheme.onBackground
+        val items = screenModel.itemState.collectAsLazyPagingItems()
 
         val snackbarHostState = remember { SnackbarHostState() }
-        var isScrollable by remember { mutableStateOf(true) }
-        var refreshAndroidView by remember { mutableStateOf(true) }
-
-        // https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view
-        val bottomBarHeight = 64.dp
-        val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
-        val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
-
-        val nestedScrollConnection = remember {
-            object : NestedScrollConnection {
-                override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
-                    val delta = available.y
-                    val newOffset = bottomBarOffsetHeightPx.floatValue + delta
-                    bottomBarOffsetHeightPx.floatValue = newOffset.coerceIn(-bottomBarHeightPx, 0f)
-
-                    return Offset.Zero
-                }
-            }
-        }
 
         if (state.imageDialogUrl != null) {
             ItemImageDialog(
@@ -120,7 +57,6 @@ class ItemScreen(
                         screenModel.shareImage(state.imageDialogUrl!!, context)
                     } else {
                         screenModel.downloadImage(state.imageDialogUrl!!, context)
-
                     }
 
                     screenModel.closeImageDialog()
@@ -135,272 +71,77 @@ class ItemScreen(
             }
         }
 
-        if (state.itemWithFeed != null) {
-            val itemWithFeed = state.itemWithFeed!!
-            val item = itemWithFeed.item
-
-            val accentColor = if (itemWithFeed.color != 0) {
-                Color(itemWithFeed.color)
-            } else {
-                primaryColor
+        LaunchedEffect(state.error) {
+            if (state.error != null) {
+                snackbarHostState.showSnackbar(state.error!!)
             }
+        }
 
-            val colorScheme = when (state.theme) {
-                "light" -> CustomTabsIntent.COLOR_SCHEME_LIGHT
-                "dark" -> CustomTabsIntent.COLOR_SCHEME_DARK
-                else -> CustomTabsIntent.COLOR_SCHEME_SYSTEM
+        when {
+            items.isLoading() -> {
+                CenteredProgressIndicator()
             }
 
-            fun openUrl(url: String) {
-                if (state.openInExternalBrowser) {
-                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
-                    context.startActivity(intent)
-                } else {
-                    CustomTabsIntent.Builder()
-                        .setDefaultColorSchemeParams(
-                            CustomTabColorSchemeParams
-                                .Builder()
-                                .setToolbarColor(accentColor.toArgb())
-                                .build()
-                        )
-                        .setShareState(CustomTabsIntent.SHARE_STATE_ON)
-                        .setUrlBarHidingEnabled(true)
-                        .setColorScheme(colorScheme)
-                        .build()
-                        .launchUrl(context, url.toUri())
-                }
+            items.isError() -> {
+                Placeholder(
+                    text = stringResource(R.string.error_occured),
+                    painter = painterResource(id = R.drawable.ic_error)
+                )
             }
 
-            Scaffold(
-                modifier = Modifier.nestedScroll(nestedScrollConnection),
-                snackbarHost = { SnackbarHost(snackbarHostState) },
-                bottomBar = {
-                    ItemScreenBottomBar(
-                        state = state.bottomBarState,
-                        accentColor = accentColor,
-                        modifier = Modifier
-                            .navigationBarsPadding()
-                            .height(bottomBarHeight)
-                            .offset {
-                                if (isScrollable) {
-                                    IntOffset(
-                                        x = 0,
-                                        y = -bottomBarOffsetHeightPx.floatValue.roundToInt()
-                                    )
-                                } else {
-                                    IntOffset(0, 0)
-                                }
-                            },
-                        onShare = { screenModel.shareItem(item, context) },
-                        onOpenUrl = { openUrl(item.link!!) },
-                        onChangeReadState = {
-                            screenModel.setItemReadState(item.apply { isRead = it })
-                        },
-                        onChangeStarState = {
-                            screenModel.setItemStarState(item.apply { isStarred = it })
-                        }
-                    )
-                }
-            ) { paddingValues ->
-                Box(
-                    modifier = Modifier.padding(paddingValues)
-                ) {
-                    AndroidView(
-                        factory = { context ->
-                            ItemNestedScrollView(
-                                context = context,
-                                useBackgroundTitle = item.imageLink != null,
-                                onGlobalLayoutListener = { viewHeight, contentHeight ->
-                                    isScrollable = viewHeight - contentHeight < 0
-                                },
-                                onUrlClick = { url -> openUrl(url) },
-                                onImageLongPress = { url -> screenModel.openImageDialog(url) }
-                            ) {
-                                if (item.imageLink != null) {
-                                    BackgroundTitle(itemWithFeed = itemWithFeed)
-                                } else {
-                                    Box {
-                                        IconButton(
-                                            onClick = { navigator.pop() },
-                                            modifier = Modifier
-                                                .statusBarsPadding()
-                                                .align(Alignment.TopStart)
-                                        ) {
-                                            Icon(
-                                                imageVector = Icons.AutoMirrored.Default.ArrowBack,
-                                                contentDescription = null,
-                                            )
-                                        }
+            else -> {
+                val pagerState = rememberPagerState(
+                    initialPage = if (itemIndex > -1) itemIndex else 0,
+                    pageCount = { items.itemCount }
+                )
 
-                                        SimpleTitle(
-                                            itemWithFeed = itemWithFeed,
-                                            titleColor = accentColor,
-                                            accentColor = accentColor,
-                                            baseColor = MaterialTheme.colorScheme.onBackground,
-                                            bottomPadding = true
-                                        )
-                                    }
+                LaunchedEffect(pagerState.currentPage) {
+                    snapshotFlow { pagerState.currentPage }
+                        .distinctUntilChanged { old, new -> old == new }
+                        .collect { pageIndex ->
+                            if (items.isNotEmpty()) {
+                                items[pageIndex]?.let {
+                                    screenModel.setItemRead(it)
                                 }
                             }
-                        },
-                        update = { nestedScrollView ->
-                            if (refreshAndroidView) {
-                                val relativeLayout =
-                                    (nestedScrollView.children.toList()[0] as RelativeLayout)
-                                val webView = relativeLayout.children.toList()[1] as ItemWebView
-
-                                webView.loadText(
-                                    itemWithFeed = itemWithFeed,
-                                    accentColor = accentColor,
-                                    backgroundColor = backgroundColor,
-                                    onBackgroundColor = onBackgroundColor
-                                )
-
-                                refreshAndroidView = false
-                            }
                         }
-                    )
                 }
-            }
-        } else {
-            CenteredProgressIndicator()
-        }
-    }
-}
 
-@Composable
-fun BackgroundTitle(
-    itemWithFeed: ItemWithFeed,
-) {
-    val navigator = LocalNavigator.currentOrThrow
-
-    val onScrimColor = Color.White.copy(alpha = 0.85f)
-    val accentColor = if (itemWithFeed.color != 0) {
-        Color(itemWithFeed.color)
-    } else {
-        onScrimColor
-    }
+                HorizontalPager(
+                    state = pagerState,
+                    beyondViewportPageCount = 2,
+                    key = items.itemKey { it.item.id }
+                ) { page ->
+                    val itemWithFeed = items[page]
+
+                    if (itemWithFeed != null) {
+                        val accentColor = if (itemWithFeed.color != 0) {
+                            Color(itemWithFeed.color)
+                        } else {
+                            MaterialTheme.colorScheme.primary
+                        }
 
-    Surface(
-        shape = RoundedCornerShape(
-            bottomStart = 24.dp,
-            bottomEnd = 24.dp
-        ),
-        modifier = Modifier.height(IntrinsicSize.Max)
-    ) {
-        AsyncImage(
-            model = itemWithFeed.item.imageLink,
-            contentDescription = null,
-            contentScale = ContentScale.Crop,
-            error = painterResource(id = R.drawable.ic_broken_image),
-            modifier = Modifier
-                .fillMaxSize()
-        )
+                        val item = itemWithFeed.item
 
-        Surface(
-            color = Color.Black.copy(alpha = 0.6f),
-            modifier = Modifier
-                .fillMaxSize()
-        ) {
-            Box {
-                IconButton(
-                    onClick = { navigator.pop() },
-                    modifier = Modifier
-                        .statusBarsPadding()
-                        .align(Alignment.TopStart)
-                ) {
-                    Icon(
-                        imageVector = Icons.AutoMirrored.Default.ArrowBack,
-                        contentDescription = null,
-                        tint = Color.White
-                    )
+                        ItemScreenPage(
+                            itemWithFeed = itemWithFeed,
+                            snackbarHostState = snackbarHostState,
+                            onOpenUrl = { url ->
+                                if (state.openInExternalBrowser) {
+                                    context.openUrl(url)
+                                } else {
+                                    context.openInCustomTab(url, state.theme, accentColor)
+                                }
+                            },
+                            onShareItem = { screenModel.shareItem(item, context) },
+                            onSetReadState = { screenModel.setItemReadState(item) },
+                            onSetStarState = { screenModel.setItemStarState(item) },
+                            onOpenImageDialog = { screenModel.openImageDialog(it) },
+                            onPop = { navigator.pop() },
+                        )
+                    }
                 }
-
-                SimpleTitle(
-                    itemWithFeed = itemWithFeed,
-                    titleColor = onScrimColor,
-                    accentColor = accentColor,
-                    baseColor = onScrimColor,
-                    bottomPadding = true
-                )
             }
         }
     }
-
-    MediumSpacer()
-}
-
-@Composable
-fun SimpleTitle(
-    itemWithFeed: ItemWithFeed,
-    titleColor: Color,
-    accentColor: Color,
-    baseColor: Color,
-    bottomPadding: Boolean,
-) {
-    val item = itemWithFeed.item
-    val spacing = MaterialTheme.spacing.mediumSpacing
-
-    Column(
-        horizontalAlignment = Alignment.CenterHorizontally,
-        modifier = Modifier
-            .fillMaxWidth()
-            .padding(
-                start = spacing,
-                end = spacing,
-                top = spacing,
-                bottom = if (bottomPadding) spacing else 0.dp
-            )
-    ) {
-        FeedIcon(
-            iconUrl = itemWithFeed.feedIconUrl,
-            name = itemWithFeed.feedName,
-            size = 48.dp,
-            modifier = Modifier.clip(CircleShape)
-        )
-
-        ShortSpacer()
-
-        Text(
-            text = itemWithFeed.feedName,
-            style = MaterialTheme.typography.labelLarge,
-            color = baseColor,
-            textAlign = TextAlign.Center
-        )
-
-        ShortSpacer()
-
-        Text(
-            text = item.title!!,
-            style = MaterialTheme.typography.headlineMedium,
-            color = titleColor,
-            textAlign = TextAlign.Center,
-            modifier = Modifier.align(Alignment.CenterHorizontally)
-        )
-
-        if (item.author != null) {
-            ShortSpacer()
-
-            IconText(
-                icon = painterResource(id = R.drawable.ic_person),
-                text = itemWithFeed.item.author!!,
-                style = MaterialTheme.typography.labelMedium,
-                color = baseColor,
-                tint = accentColor
-            )
-        }
-
-        ShortSpacer()
-
-        val readTime = if (item.readTime > 1) {
-            stringResource(id = R.string.read_time, item.readTime.roundToInt())
-        } else {
-            stringResource(id = R.string.read_time_lower_than_1)
-        }
-        Text(
-            text = "${DateUtils.formattedDate(item.pubDate!!)} · $readTime",
-            style = MaterialTheme.typography.labelMedium,
-            color = baseColor
-        )
-    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt b/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt
index 19699e0e3..fa3c4e8c3 100644
--- a/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt
+++ b/app/src/main/java/com/readrops/app/item/ItemScreenModel.kt
@@ -1,58 +1,110 @@
 package com.readrops.app.item
 
+import android.content.ClipData
 import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
 import android.graphics.Bitmap
-import android.graphics.drawable.BitmapDrawable
+import android.media.MediaScannerConnection
 import android.net.Uri
 import android.os.Environment
 import androidx.compose.runtime.Stable
 import androidx.core.content.FileProvider
+import androidx.paging.LoadState
+import androidx.paging.LoadStates
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.PagingSource
+import androidx.paging.cachedIn
+import androidx.paging.map
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
-import coil.imageLoader
-import coil.request.ImageRequest
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import coil3.request.allowHardware
+import coil3.toBitmap
+import com.readrops.app.R
 import com.readrops.app.repositories.BaseRepository
+import com.readrops.app.util.PAGING_PAGE_SIZE
+import com.readrops.app.util.PAGING_PREFETCH_DISTANCE
 import com.readrops.app.util.Preferences
+import com.readrops.app.util.Utils
 import com.readrops.db.Database
 import com.readrops.db.entities.Item
 import com.readrops.db.entities.account.Account
 import com.readrops.db.entities.account.AccountType
+import com.readrops.db.filters.MainFilter
+import com.readrops.db.filters.QueryFilters
 import com.readrops.db.pojo.ItemWithFeed
 import com.readrops.db.queries.ItemSelectionQueryBuilder
+import com.readrops.db.queries.ItemsQueryBuilder
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
 import org.koin.core.parameter.parametersOf
 import java.io.File
-import java.io.FileOutputStream
+import java.net.URI
 
-@OptIn(ExperimentalCoroutinesApi::class)
 class ItemScreenModel(
-    private val database: Database,
     private val itemId: Int,
+    private val itemIndex: Int,
+    private val queryFilters: QueryFilters,
+    private val database: Database,
     private val preferences: Preferences,
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
 ) : StateScreenModel(ItemState()), KoinComponent {
 
-    //TODO Is this really the best solution?
-    lateinit var account: Account
-    lateinit var repository: BaseRepository
+    //TODO Is  really the best solution?
+    private lateinit var account: Account
+    private lateinit var repository: BaseRepository
+    private lateinit var pagingSource: PagingSource
+
+    private val useCustomShareIntentTpl = preferences.useCustomShareIntentTpl.flow.stateIn(
+        screenModelScope, SharingStarted.Eagerly, false
+    )
+    private val customShareIntentTpl = preferences.customShareIntentTpl.flow.stateIn(
+        screenModelScope, SharingStarted.Eagerly, ""
+    )
+
+    private val useStateChanges = itemIndex > -1 && (queryFilters.mainFilter != MainFilter.ALL
+            || !queryFilters.showReadItems)
+
+    private val _itemState: MutableStateFlow> =
+        MutableStateFlow(
+            PagingData.empty(
+                sourceLoadStates = LoadStates(
+                    refresh = LoadState.Loading,
+                    prepend = LoadState.Loading,
+                    append = LoadState.Loading
+                )
+            )
+        )
+    // based type is Flow because with StateFlow when coming back from process death, pager doesn't resume
+    // it might be due to stateIn() overlapping cachedIn(), but not sure
+    var itemState: Flow> = _itemState.asStateFlow()
 
     init {
         screenModelScope.launch(dispatcher) {
             database.accountDao().selectCurrentAccount()
-                .flatMapLatest { account ->
+                .collect { account ->
                     this@ItemScreenModel.account = account!!
 
-                    if (account.accountType == AccountType.FEVER) {
+                    // With Fever, we notify directly the server about state changes
+                    // so we need account credentials
+                    if (account.type == AccountType.FEVER) {
                         get().apply {
                             account.login = getString(account.loginKey, null)
                             account.password = getString(account.passwordKey, null)
@@ -61,22 +113,18 @@ class ItemScreenModel(
 
                     repository = get { parametersOf(account) }
 
-                    val query = ItemSelectionQueryBuilder.buildQuery(
-                        itemId = itemId,
-                        separateState = account.config.useSeparateState
-                    )
-
-                    database.itemDao().selectItemById(query)
-                }
-                .collect { itemWithFeed ->
-                    mutableState.update {
-                        it.copy(
-                            itemWithFeed = itemWithFeed,
-                            bottomBarState = BottomBarState(
-                                isRead = itemWithFeed.item.isRead,
-                                isStarred = itemWithFeed.item.isStarred
-                            )
+                    if (itemIndex > -1) {
+                        itemState = buildPager()
+                    } else {
+                        val query = ItemSelectionQueryBuilder.buildQuery(
+                            itemId = itemId,
+                            separateState = account.config.useSeparateState
                         )
+
+                        database.itemDao().selectItemById(query)
+                            .collect { itemWithFeed ->
+                                _itemState.update { PagingData.from(listOf(itemWithFeed)) }
+                            }
                     }
                 }
         }
@@ -101,25 +149,128 @@ class ItemScreenModel(
         }
     }
 
-    fun shareItem(item: Item, context: Context) {
-        Intent().apply {
-            action = Intent.ACTION_SEND
-            type = "text/plain"
-            putExtra(Intent.EXTRA_TEXT, item.link)
-        }.also {
-            context.startActivity(Intent.createChooser(it, null))
+    private fun createPagingSource(): PagingSource {
+        val query = ItemsQueryBuilder.buildItemsQuery(
+            queryFilters = queryFilters,
+            separateState = account.config.useSeparateState
+        )
+
+        return database.itemDao().selectAll(query).apply {
+            pagingSource = this
+        }
+    }
+
+    private fun buildPager(): Flow> {
+        val pageNb = (((itemIndex + PAGING_PAGE_SIZE - 1) / PAGING_PAGE_SIZE) + 1)
+            .coerceAtLeast(1)
+
+        return Pager(
+            config = PagingConfig(
+                initialLoadSize = PAGING_PAGE_SIZE * pageNb,
+                pageSize = PAGING_PAGE_SIZE,
+                prefetchDistance = PAGING_PREFETCH_DISTANCE
+            ),
+            pagingSourceFactory = { createPagingSource() }
+        )
+            .flow
+            .map {
+                it.map { itemWithFeed ->
+                    val stateChange = state.value.stateChanges
+                        .firstOrNull { stateChange -> stateChange.itemId == itemWithFeed.item.id }
+
+                    if (stateChange != null) {
+                        itemWithFeed.copy(
+                            isRead = if (stateChange.readChange) {
+                                !itemWithFeed.isRead
+                            } else {
+                                itemWithFeed.isRead
+                            },
+                            isStarred = if (stateChange.starChange) {
+                                !itemWithFeed.isStarred
+                            } else {
+                                itemWithFeed.isStarred
+                            }
+                        )
+                    } else {
+                        itemWithFeed
+                    }
+                }
+            }
+            .cachedIn(screenModelScope)
+    }
+
+    // TODO this must be tested one way or another
+    private fun updateStateChange(item: Item, readChange: Boolean) {
+        val stateChange = state.value.stateChanges.firstOrNull { it.itemId == item.id }
+
+        if (stateChange != null) {
+            val newStateChange = if (readChange) {
+                stateChange.copy(readChange = !stateChange.readChange)
+            } else {
+                stateChange.copy(starChange = !stateChange.starChange)
+            }
+
+            if (!newStateChange.readChange && !newStateChange.starChange) {
+                mutableState.update {
+                    it.copy(stateChanges = it.stateChanges.filterNot { stateChange -> stateChange.itemId == item.id })
+                }
+            } else {
+                mutableState.update {
+                    it.copy(stateChanges = it.stateChanges.map { mapStateChange ->
+                        if (mapStateChange.itemId == item.id) {
+                            newStateChange
+                        } else {
+                            mapStateChange
+                        }
+                    })
+                }
+            }
+        } else {
+            mutableState.update {
+                it.copy(
+                    stateChanges = it.stateChanges + if (readChange) {
+                        StateChange(
+                            item = item,
+                            readChange = true
+                        )
+                    } else {
+                        StateChange(
+                            item = item,
+                            starChange = true
+                        )
+                    }
+                )
+            }
+        }
+    }
+
+    fun setItemRead(itemWithFeed: ItemWithFeed) {
+        val item = itemWithFeed.item
+
+        if (!itemWithFeed.isRead && !state.value.stateChanges.any { it.item.id == item.id }) {
+            setItemReadState(item)
         }
     }
 
     fun setItemReadState(item: Item) {
-        screenModelScope.launch(dispatcher) {
-            repository.setItemReadState(item)
+        if (useStateChanges) {
+            updateStateChange(item, readChange = true)
+            pagingSource.invalidate()
+        } else {
+            screenModelScope.launch(dispatcher) {
+                repository.setItemReadState(item.apply { isRead = !isRead })
+            }
         }
     }
 
     fun setItemStarState(item: Item) {
-        screenModelScope.launch(dispatcher) {
-            repository.setItemStarState(item)
+        if (useStateChanges) {
+            updateStateChange(item, readChange = false)
+            pagingSource.invalidate()
+        } else {
+            screenModelScope.launch(dispatcher) {
+                repository.setItemStarState(item.apply { isStarred = !isStarred })
+            }
         }
     }
 
@@ -131,16 +282,23 @@ class ItemScreenModel(
         screenModelScope.launch(dispatcher) {
             val bitmap = getImage(url, context)
 
+            if (bitmap == null) {
+                mutableState.update { it.copy(error = context.getString(R.string.error_image_download)) }
+                return@launch
+            }
+
             val target = File(
                 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                 url.substringAfterLast('/')
-            )
-            FileOutputStream(target).apply {
-                bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
-                flush()
-                close()
+            ).apply {
+                outputStream().apply {
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
+                    flush()
+                    close()
+                }
             }
 
+            MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), null, null)
             mutableState.update { it.copy(fileDownloadedEvent = true) }
         }
     }
@@ -148,50 +306,98 @@ class ItemScreenModel(
     fun shareImage(url: String, context: Context) {
         screenModelScope.launch(dispatcher) {
             val bitmap = getImage(url, context)
+            if (bitmap == null) {
+                mutableState.update { it.copy(error = context.getString(R.string.error_image_download)) }
+                return@launch
+            }
+
             val uri = saveImageInCache(bitmap, url, context)
 
             Intent().apply {
                 action = Intent.ACTION_SEND
-                type = "image/*"
+
+                clipData = ClipData.newRawUri(null, uri)
                 putExtra(Intent.EXTRA_STREAM, uri)
+
+                type = "image/*"
+                flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
             }.also {
                 context.startActivity(Intent.createChooser(it, null))
             }
         }
     }
 
-    private suspend fun getImage(url: String, context: Context): Bitmap {
+    private suspend fun getImage(url: String, context: Context): Bitmap? {
         val downloader = context.imageLoader
 
-        return (downloader.execute(
+        val image = downloader.execute(
             ImageRequest.Builder(context)
                 .data(url)
                 .allowHardware(false)
                 .build()
-        ).drawable as BitmapDrawable).bitmap
+        ).image
+
+        return image?.toBitmap()
     }
 
     private fun saveImageInCache(bitmap: Bitmap, url: String, context: Context): Uri {
         val imagesFolder = File(context.cacheDir.absolutePath, "images")
         if (!imagesFolder.exists()) imagesFolder.mkdirs()
 
-        val image = File(imagesFolder, url.substringAfterLast('/'))
-        FileOutputStream(image).apply {
-            bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
-            flush()
-            close()
+        val name = URI.create(url).path.substringAfterLast('/')
+        val image = File(imagesFolder, name).apply {
+            outputStream().apply {
+                bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
+                flush()
+                close()
+            }
         }
 
         return FileProvider.getUriForFile(context, context.packageName, image)
     }
+
+    fun shareItem(item: Item, context: Context) = Utils.shareItem(
+        item, context, useCustomShareIntentTpl.value, customShareIntentTpl.value
+    )
+
+    override fun onDispose() {
+        screenModelScope.launch(dispatcher) {
+            withContext(NonCancellable) {
+                repository.setItemsRead(
+                    items = state.value.stateChanges
+                        .filter { it.readChange }
+                        .map { it.item }
+                )
+
+                state.value.stateChanges
+                    .filter { it.starChange }
+                    .forEach {
+                        repository.setItemStarState(it.item.apply {
+                            isStarred = !isStarred
+                        })
+                    }
+            }
+        }
+    }
 }
 
 @Stable
 data class ItemState(
-    val itemWithFeed: ItemWithFeed? = null,
-    val bottomBarState: BottomBarState = BottomBarState(),
     val imageDialogUrl: String? = null,
     val fileDownloadedEvent: Boolean = false,
     val openInExternalBrowser: Boolean = false,
-    val theme: String? = ""
-)
\ No newline at end of file
+    val theme: String? = "",
+    val error: String? = null,
+    val stateChanges: List = listOf()
+)
+
+@Stable
+data class StateChange(
+    val item: Item,
+    val starChange: Boolean = false,
+    val readChange: Boolean = false
+) {
+
+    val itemId: Int
+        get() = item.id
+}
diff --git a/app/src/main/java/com/readrops/app/item/ItemScreenPage.kt b/app/src/main/java/com/readrops/app/item/ItemScreenPage.kt
new file mode 100644
index 000000000..7582101ac
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/item/ItemScreenPage.kt
@@ -0,0 +1,157 @@
+package com.readrops.app.item
+
+import android.widget.RelativeLayout
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.view.children
+import com.readrops.app.item.components.BackgroundTitle
+import com.readrops.app.item.components.BottomBarState
+import com.readrops.app.item.components.ItemScreenBottomBar
+import com.readrops.app.item.components.SimpleTitle
+import com.readrops.app.item.components.rememberBottomBarNestedScrollConnection
+import com.readrops.app.item.view.ItemNestedScrollView
+import com.readrops.app.item.view.ItemWebView
+import com.readrops.app.util.extensions.displayColor
+import com.readrops.db.pojo.ItemWithFeed
+
+@Composable
+fun ItemScreenPage(
+    itemWithFeed: ItemWithFeed,
+    snackbarHostState: SnackbarHostState,
+    onOpenUrl: (String) -> Unit,
+    onShareItem: () -> Unit,
+    onSetReadState: (Boolean) -> Unit,
+    onSetStarState: (Boolean) -> Unit,
+    onOpenImageDialog: (String) -> Unit,
+    onPop: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val item = itemWithFeed.item
+
+    val backgroundColor = MaterialTheme.colorScheme.background
+    val onBackgroundColor = MaterialTheme.colorScheme.onBackground
+
+    val accentColor = itemWithFeed.displayColor(MaterialTheme.colorScheme.background.toArgb())
+
+    val nestedScrollConnection = rememberBottomBarNestedScrollConnection()
+    var refreshAndroidView by remember { mutableStateOf(true) }
+    var isScrollable by remember { mutableStateOf(true) }
+
+    Scaffold(
+        modifier = modifier.nestedScroll(nestedScrollConnection)
+            .navigationBarsPadding(),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        bottomBar = {
+            ItemScreenBottomBar(
+                state = BottomBarState(
+                    isRead = itemWithFeed.isRead,
+                    isStarred = itemWithFeed.isStarred,
+                    isOpenUrlVisible = !item.link.isNullOrEmpty()
+                ),
+                accentColor = accentColor,
+                modifier = Modifier
+                    .navigationBarsPadding()
+                    .height(nestedScrollConnection.bottomBarHeight)
+                    .offset {
+                        if (isScrollable) {
+                            IntOffset(
+                                x = 0,
+                                y = -nestedScrollConnection.bottomBarOffset
+                            )
+                        } else {
+                            IntOffset(0, 0)
+                        }
+                    },
+                onShare = onShareItem,
+                onOpenUrl = { onOpenUrl(item.link!!) },
+                onChangeReadState = onSetReadState,
+                onChangeStarState = onSetStarState
+            )
+        }
+    ) { paddingValues ->
+        Box(
+            modifier = Modifier.padding(paddingValues)
+        ) {
+            AndroidView(
+                factory = { context ->
+                    ItemNestedScrollView(
+                        context = context,
+                        useBackgroundTitle = item.imageLink != null,
+                        onGlobalLayoutListener = { viewHeight, contentHeight ->
+                            isScrollable = viewHeight - contentHeight < 0
+                        },
+                        onUrlClick = { url -> onOpenUrl(url) },
+                        onImageLongPress = { url -> onOpenImageDialog(url) }
+                    ) {
+                        if (item.imageLink != null) {
+                            BackgroundTitle(
+                                itemWithFeed = itemWithFeed,
+                                onClickBack = onPop
+                            )
+                        } else {
+                            Box {
+                                IconButton(
+                                    onClick = onPop,
+                                    modifier = Modifier
+                                        .statusBarsPadding()
+                                        .align(Alignment.TopStart)
+                                ) {
+                                    Icon(
+                                        imageVector = Icons.AutoMirrored.Default.ArrowBack,
+                                        contentDescription = null,
+                                    )
+                                }
+
+                                SimpleTitle(
+                                    itemWithFeed = itemWithFeed,
+                                    titleColor = accentColor,
+                                    onBackgroundColor = MaterialTheme.colorScheme.onBackground,
+                                    bottomPadding = true
+                                )
+                            }
+                        }
+                    }
+                },
+                update = { nestedScrollView ->
+                    if (refreshAndroidView) {
+                        val relativeLayout =
+                            (nestedScrollView.children.toList().first() as RelativeLayout)
+                        val webView = relativeLayout.children.toList()[1] as ItemWebView
+
+                        webView.loadText(
+                            itemWithFeed = itemWithFeed,
+                            accentColor = accentColor,
+                            backgroundColor = backgroundColor,
+                            onBackgroundColor = onBackgroundColor
+                        )
+
+                        refreshAndroidView = false
+                    }
+                }
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/item/components/BackgroundTitle.kt b/app/src/main/java/com/readrops/app/item/components/BackgroundTitle.kt
new file mode 100644
index 000000000..f75f41481
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/item/components/BackgroundTitle.kt
@@ -0,0 +1,93 @@
+package com.readrops.app.item.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.readrops.app.R
+import com.readrops.app.timelime.components.itemWithFeed
+import com.readrops.app.util.DefaultPreview
+import com.readrops.app.util.theme.MediumSpacer
+import com.readrops.app.util.theme.ReadropsTheme
+import com.readrops.db.pojo.ItemWithFeed
+
+@Composable
+fun BackgroundTitle(
+    itemWithFeed: ItemWithFeed,
+    onClickBack: () -> Unit,
+) {
+    val onScrimColor = Color.White.copy(alpha = 0.85f)
+
+    Surface(
+        shape = RoundedCornerShape(
+            bottomStart = 24.dp,
+            bottomEnd = 24.dp
+        ),
+        modifier = Modifier.height(IntrinsicSize.Max)
+    ) {
+        AsyncImage(
+            model = itemWithFeed.item.imageLink,
+            contentDescription = null,
+            contentScale = ContentScale.Crop,
+            error = painterResource(id = R.drawable.ic_broken_image),
+            modifier = Modifier
+                .fillMaxSize()
+        )
+
+        Surface(
+            color = Color.Black.copy(alpha = 0.6f),
+            modifier = Modifier
+                .fillMaxSize()
+        ) {
+            Box {
+                IconButton(
+                    onClick = onClickBack,
+                    modifier = Modifier
+                        .statusBarsPadding()
+                        .align(Alignment.TopStart)
+                ) {
+                    Icon(
+                        imageVector = Icons.AutoMirrored.Default.ArrowBack,
+                        contentDescription = null,
+                        tint = Color.White
+                    )
+                }
+
+                SimpleTitle(
+                    itemWithFeed = itemWithFeed,
+                    titleColor = onScrimColor,
+                    onBackgroundColor = onScrimColor,
+                    bottomPadding = true
+                )
+            }
+        }
+    }
+
+    MediumSpacer()
+}
+
+@DefaultPreview
+@Composable
+private fun BackgroundTitlePreview() {
+    ReadropsTheme {
+        BackgroundTitle(
+            itemWithFeed = itemWithFeed,
+            onClickBack = {}
+        )
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/item/components/BottomBarNestedScrollConnection.kt b/app/src/main/java/com/readrops/app/item/components/BottomBarNestedScrollConnection.kt
new file mode 100644
index 000000000..319143ff0
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/item/components/BottomBarNestedScrollConnection.kt
@@ -0,0 +1,38 @@
+package com.readrops.app.item.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
+
+@Composable
+fun rememberBottomBarNestedScrollConnection(density: Density = LocalDensity.current) =
+    remember { BottomBarNestedScrollConnection(density) }
+
+class BottomBarNestedScrollConnection(
+    density: Density,
+    val bottomBarHeight: Dp = 64.dp,
+) : NestedScrollConnection {
+
+    private val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
+
+    var bottomBarOffset: Int by mutableIntStateOf(0)
+        private set
+
+    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+        val delta = available.y
+        val newOffset = bottomBarOffset.toFloat() + delta
+        bottomBarOffset = newOffset.coerceIn(-bottomBarHeightPx, 0f).roundToInt()
+
+        return Offset.Zero
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt b/app/src/main/java/com/readrops/app/item/components/ItemScreenBottomBar.kt
similarity index 64%
rename from app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt
rename to app/src/main/java/com/readrops/app/item/components/ItemScreenBottomBar.kt
index c15af3e0a..b60a96452 100644
--- a/app/src/main/java/com/readrops/app/item/ItemScreenBottomBar.kt
+++ b/app/src/main/java/com/readrops/app/item/components/ItemScreenBottomBar.kt
@@ -1,4 +1,4 @@
-package com.readrops.app.item
+package com.readrops.app.item.components
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Row
@@ -16,12 +16,15 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.res.painterResource
 import com.readrops.app.R
-import com.readrops.app.util.FeedColors
+import com.readrops.app.util.DefaultPreview
+import com.readrops.app.util.extensions.canDisplayOnBackground
+import com.readrops.app.util.theme.ReadropsTheme
 import com.readrops.app.util.theme.spacing
 
 data class BottomBarState(
     val isRead: Boolean = false,
-    val isStarred: Boolean = false
+    val isStarred: Boolean = false,
+    val isOpenUrlVisible: Boolean = true
 )
 
 @Composable
@@ -34,10 +37,9 @@ fun ItemScreenBottomBar(
     onChangeStarState: (Boolean) -> Unit,
     modifier: Modifier = Modifier
 ) {
-    val tint = if (FeedColors.isColorDark(accentColor.toArgb()))
-        Color.White
-    else
-        Color.Black
+    val onAccentColor =
+        if (Color.White.toArgb().canDisplayOnBackground(accentColor.toArgb(), threshold = 2.5f))
+            Color.White else Color.Black
 
     Surface(
         color = accentColor,
@@ -56,7 +58,7 @@ fun ItemScreenBottomBar(
                             R.drawable.ic_remove_done
                         else R.drawable.ic_done_all
                     ),
-                    tint = tint,
+                    tint = onAccentColor,
                     contentDescription = null
                 )
             }
@@ -70,7 +72,7 @@ fun ItemScreenBottomBar(
                             R.drawable.ic_star
                         else R.drawable.ic_star_outline
                     ),
-                    tint = tint,
+                    tint = onAccentColor,
                     contentDescription = null
                 )
             }
@@ -80,20 +82,40 @@ fun ItemScreenBottomBar(
             ) {
                 Icon(
                     imageVector = Icons.Default.Share,
-                    tint = tint,
+                    tint = onAccentColor,
                     contentDescription = null
                 )
             }
 
-            IconButton(
-                onClick = onOpenUrl
-            ) {
-                Icon(
-                    painter = painterResource(id = R.drawable.ic_open_in_browser),
-                    tint = tint,
-                    contentDescription = null
-                )
+            if (state.isOpenUrlVisible) {
+                IconButton(
+                    onClick = onOpenUrl
+                ) {
+                    Icon(
+                        painter = painterResource(id = R.drawable.ic_open_in_browser),
+                        tint = onAccentColor,
+                        contentDescription = null
+                    )
+                }
             }
         }
     }
 }
+
+@DefaultPreview
+@Composable
+private fun ItemScreenBottomBarPreview() {
+    ReadropsTheme {
+        ItemScreenBottomBar(
+            state = BottomBarState(
+                isRead = false,
+                isStarred = false
+            ),
+            accentColor = MaterialTheme.colorScheme.primary,
+            onShare = {},
+            onOpenUrl = {},
+            onChangeReadState = {},
+            onChangeStarState = {},
+        )
+    }
+}
diff --git a/app/src/main/java/com/readrops/app/item/components/SimpleTitle.kt b/app/src/main/java/com/readrops/app/item/components/SimpleTitle.kt
new file mode 100644
index 000000000..c23b67c4a
--- /dev/null
+++ b/app/src/main/java/com/readrops/app/item/components/SimpleTitle.kt
@@ -0,0 +1,117 @@
+package com.readrops.app.item.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.graphics.toArgb
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.readrops.app.R
+import com.readrops.app.timelime.components.itemWithFeed
+import com.readrops.app.util.DefaultPreview
+import com.readrops.app.util.components.FeedIcon
+import com.readrops.app.util.components.IconText
+import com.readrops.app.util.extensions.displayColor
+import com.readrops.app.util.theme.ReadropsTheme
+import com.readrops.app.util.theme.ShortSpacer
+import com.readrops.app.util.theme.spacing
+import com.readrops.db.pojo.ItemWithFeed
+import com.readrops.db.util.DateUtils
+import kotlin.math.roundToInt
+
+@Composable
+fun SimpleTitle(
+    itemWithFeed: ItemWithFeed,
+    titleColor: Color,
+    onBackgroundColor: Color,
+    bottomPadding: Boolean,
+) {
+    val item = itemWithFeed.item
+    val spacing = MaterialTheme.spacing.mediumSpacing
+
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(
+                start = spacing,
+                end = spacing,
+                top = spacing,
+                bottom = if (bottomPadding) spacing else 0.dp
+            )
+    ) {
+        FeedIcon(
+            iconUrl = itemWithFeed.feedIconUrl,
+            name = itemWithFeed.feedName,
+            size = 48.dp,
+            modifier = Modifier.clip(CircleShape)
+        )
+
+        ShortSpacer()
+
+        Text(
+            text = itemWithFeed.feedName,
+            style = MaterialTheme.typography.labelLarge,
+            color = onBackgroundColor,
+            textAlign = TextAlign.Center
+        )
+
+        ShortSpacer()
+
+        Text(
+            text = item.title!!,
+            style = MaterialTheme.typography.headlineMedium,
+            color = titleColor,
+            textAlign = TextAlign.Center,
+            modifier = Modifier.align(Alignment.CenterHorizontally)
+        )
+
+        if (item.author != null) {
+            ShortSpacer()
+
+            IconText(
+                icon = painterResource(id = R.drawable.ic_person),
+                text = itemWithFeed.item.author!!,
+                style = MaterialTheme.typography.labelMedium,
+                color = onBackgroundColor,
+                tint = itemWithFeed.displayColor(MaterialTheme.colorScheme.background.toArgb())
+            )
+        }
+
+        ShortSpacer()
+
+        val readTime = if (item.readTime > 1) {
+            stringResource(id = R.string.read_time, item.readTime.roundToInt())
+        } else {
+            stringResource(id = R.string.read_time_lower_than_1)
+        }
+        Text(
+            text = "${DateUtils.formattedDateByLocal(item.pubDate!!)} ${stringResource(id = R.string.interpoint)} $readTime",
+            style = MaterialTheme.typography.labelMedium,
+            color = onBackgroundColor
+        )
+    }
+}
+
+@DefaultPreview
+@Composable
+private fun SimpleTitlePreview() {
+    ReadropsTheme {
+        SimpleTitle(
+            itemWithFeed = itemWithFeed,
+            titleColor = MaterialTheme.colorScheme.primary,
+            onBackgroundColor = MaterialTheme.colorScheme.onBackground,
+            bottomPadding = true
+        )
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt b/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt
index f3755adf9..c6ece9937 100644
--- a/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt
+++ b/app/src/main/java/com/readrops/app/item/view/ItemNestedScrollView.kt
@@ -22,6 +22,7 @@ class ItemNestedScrollView(
         addView(
             RelativeLayout(context).apply {
                 ViewCompat.setNestedScrollingEnabled(this, true)
+                descendantFocusability = FOCUS_BLOCK_DESCENDANTS
 
                 val composeView = ComposeView(context).apply {
                     id = 1
diff --git a/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt b/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt
index edc5f7333..bae220721 100644
--- a/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt
+++ b/app/src/main/java/com/readrops/app/item/view/ItemWebView.kt
@@ -2,11 +2,13 @@ package com.readrops.app.item.view
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.text.SpannedString
 import android.util.AttributeSet
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
+import androidx.core.text.HtmlCompat
 import androidx.core.text.layoutDirection
 import com.readrops.app.R
 import com.readrops.app.util.Utils
@@ -78,17 +80,22 @@ class ItemWebView(
     }
 
     private fun formatText(itemWithFeed: ItemWithFeed): String {
-        return if (itemWithFeed.item.text != null) {
-            val document = if (itemWithFeed.websiteUrl != null) Jsoup.parse(
-                Parser.unescapeEntities(itemWithFeed.item.text, false), itemWithFeed.websiteUrl
-            ) else Jsoup.parse(
-                Parser.unescapeEntities(itemWithFeed.item.text, false)
-            )
-
-            document.select("div,span").forEach { it.clearAttributes() }
-            return document.body().html()
+        val text = itemWithFeed.item.text ?: return ""
+        val unescapedText = Parser.unescapeEntities(text, false)
+        val document = if (itemWithFeed.websiteUrl != null) {
+            Jsoup.parse(unescapedText, itemWithFeed.websiteUrl!!)
+        } else {
+            Jsoup.parse(unescapedText)
+        }
+        // If body has no tags or all tags are unknown (and therefore likely not HTML tags at all),
+        // treat the whole thing as plain text and convert it to HTML turning newlines into 
/

tags + val body = document.body() + val isPlainText = body.stream().skip(1).allMatch { !it.tag().isKnownTag } + return if (isPlainText) { + HtmlCompat.toHtml(SpannedString(unescapedText), HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) } else { - "" + body.select("div,span").forEach { it.clearAttributes() } + body.html() } } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/MoreTab.kt b/app/src/main/java/com/readrops/app/more/MoreTab.kt index 4a345a6ff..f5e40db8f 100644 --- a/app/src/main/java/com/readrops/app/more/MoreTab.kt +++ b/app/src/main/java/com/readrops/app/more/MoreTab.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -31,10 +32,11 @@ import cafe.adriel.voyager.navigator.tab.TabOptions import com.readrops.app.BuildConfig import com.readrops.app.R import com.readrops.app.account.selection.adaptiveIconPainterResource +import com.readrops.app.more.debug.DebugScreen import com.readrops.app.more.preferences.PreferencesScreen import com.readrops.app.util.components.IconText import com.readrops.app.util.components.SelectableIconText -import com.readrops.app.util.openUrl +import com.readrops.app.util.extensions.openUrl import com.readrops.app.util.theme.LargeSpacer import com.readrops.app.util.theme.MediumSpacer import com.readrops.app.util.theme.ShortSpacer @@ -64,115 +66,130 @@ object MoreTab : Tab, KoinComponent { ) } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - ) { - LargeSpacer() - - Image( - painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), - contentDescription = null, - modifier = Modifier.size(64.dp) - ) - - MediumSpacer() - - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge - ) - - ShortSpacer() - - IconText( - text = if (BuildConfig.DEBUG) { - "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" - } else { - "v${BuildConfig.VERSION_NAME}" - }, - icon = painterResource(id = R.drawable.ic_version), - style = MaterialTheme.typography.labelLarge - ) - - ShortSpacer() - - Text( - text = stringResource(id = R.string.app_licence), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - ShortSpacer() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + Surface(color = MaterialTheme.colorScheme.background) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() ) { - IconButton( - onClick = { context.openUrl(context.getString(R.string.app_url)) } + LargeSpacer() + + Image( + painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + MediumSpacer() + + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + ShortSpacer() + + IconText( + text = if (BuildConfig.DEBUG) { + "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + } else { + "v${BuildConfig.VERSION_NAME}" + }, + icon = painterResource(id = R.drawable.ic_version), + style = MaterialTheme.typography.labelLarge + ) + + ShortSpacer() + + Text( + text = stringResource(id = R.string.app_licence), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { - Icon( - painter = painterResource(id = R.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) + IconButton( + onClick = { context.openUrl(context.getString(R.string.app_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { context.openUrl(context.getString(R.string.changelog_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_changelog), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { context.openUrl(context.getString(R.string.app_issues_url)) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_bug_report), + contentDescription = null + ) + } } - IconButton( - onClick = { context.openUrl(context.getString(R.string.changelog_url)) } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_changelog), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - - IconButton( - onClick = { context.openUrl(context.getString(R.string.app_issues_url)) } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_bug_report), - contentDescription = null + MediumSpacer() + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_settings), + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { navigator.push(PreferencesScreen()) } + ) + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_library), + text = stringResource(id = R.string.open_source_libraries), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { navigator.push(AboutLibrariesScreen()) } + ) + + SelectableIconText( + icon = painterResource(id = R.drawable.ic_donation), + text = stringResource(id = R.string.make_donation), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { showDonationDialog = true } + ) + + if (BuildConfig.DEBUG) { + SelectableIconText( + icon = painterResource(id = R.drawable.ic_bug), + text = "Debug", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), + spacing = MaterialTheme.spacing.largeSpacing, + padding = MaterialTheme.spacing.mediumSpacing, + tint = MaterialTheme.colorScheme.primary, + onClick = { navigator.push(DebugScreen()) } ) } } - - MediumSpacer() - - SelectableIconText( - icon = painterResource(id = R.drawable.ic_settings), - text = stringResource(R.string.settings), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), - spacing = MaterialTheme.spacing.largeSpacing, - padding = MaterialTheme.spacing.mediumSpacing, - tint = MaterialTheme.colorScheme.primary, - onClick = { navigator.push(PreferencesScreen()) } - ) - - SelectableIconText( - icon = painterResource(id = R.drawable.ic_library), - text = stringResource(id = R.string.open_source_libraries), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), - spacing = MaterialTheme.spacing.largeSpacing, - padding = MaterialTheme.spacing.mediumSpacing, - tint = MaterialTheme.colorScheme.primary, - onClick = { navigator.push(AboutLibrariesScreen()) } - ) - - SelectableIconText( - icon = painterResource(id = R.drawable.ic_donation), - text = stringResource(id = R.string.make_donation), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal), - spacing = MaterialTheme.spacing.largeSpacing, - padding = MaterialTheme.spacing.mediumSpacing, - tint = MaterialTheme.colorScheme.primary, - onClick = { showDonationDialog = true } - ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/debug/DebugScreen.kt b/app/src/main/java/com/readrops/app/more/debug/DebugScreen.kt new file mode 100644 index 000000000..1defd5413 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/debug/DebugScreen.kt @@ -0,0 +1,115 @@ +package com.readrops.app.more.debug + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Builder +import androidx.core.app.NotificationManagerCompat +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.readrops.app.MainActivity +import com.readrops.app.R +import com.readrops.app.ReadropsApp +import com.readrops.app.more.preferences.components.BasePreference +import com.readrops.app.more.preferences.components.PreferenceHeader +import com.readrops.app.sync.SyncWorker.Companion.ACCOUNT_ID_KEY +import com.readrops.app.sync.SyncWorker.Companion.ITEM_ID_KEY +import com.readrops.app.sync.SyncWorker.Companion.SYNC_RESULT_NOTIFICATION_ID +import com.readrops.app.util.components.AndroidScreen +import com.readrops.db.Database +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +class DebugScreen : AndroidScreen(), KoinComponent { + + @OptIn(ExperimentalMaterial3Api::class) + @SuppressLint("MissingPermission") + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val coroutineScope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Debug") }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier.padding(paddingValues) + ) { + PreferenceHeader(stringResource(R.string.notifications)) + + BasePreference( + title = "Send notification: Single item from single feed", + onClick = { + coroutineScope.launch { + val database = get() + + val item = database.itemDao().selectFirst() + val account = database.accountDao().selectCurrentAccount().first() + + val intent = Intent(context, MainActivity::class.java).apply { + putExtra(ACCOUNT_ID_KEY, account!!.id) + putExtra(ITEM_ID_KEY, item.id) + } + + val notificationBuilder = Builder(context, ReadropsApp.SYNC_CHANNEL_ID) + .setContentTitle(item.title) + .setContentText("Test notification") + .setStyle( + NotificationCompat.BigTextStyle().bigText("Test notification") + ) + .setSmallIcon(R.drawable.ic_notifications) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + + get().notify( + SYNC_RESULT_NOTIFICATION_ID, + notificationBuilder.build() + ) + } + } + ) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt index 5bd1e6a9f..a198e9f23 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreen.kt @@ -8,7 +8,10 @@ import android.os.PowerManager import android.provider.Settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -27,15 +30,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.readrops.app.R import com.readrops.app.more.preferences.components.BasePreference +import com.readrops.app.more.preferences.components.CustomShareIntentTextWidget import com.readrops.app.more.preferences.components.ListPreferenceWidget import com.readrops.app.more.preferences.components.PreferenceHeader import com.readrops.app.more.preferences.components.SwitchPreferenceWidget import com.readrops.app.sync.SyncWorker +import com.readrops.app.timelime.components.SwipeAction import com.readrops.app.util.components.AndroidScreen import com.readrops.app.util.components.CenteredProgressIndicator import kotlinx.coroutines.launch @@ -48,8 +53,9 @@ class PreferencesScreen : AndroidScreen() { override fun Content() { val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current - val screenModel = getScreenModel() + val screenModel = koinScreenModel() + val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -84,7 +90,12 @@ class PreferencesScreen : AndroidScreen() { else -> { val loadedState = (state as PreferencesScreenState.Loaded) - Column { + // a lazyColumn might be necessary in the future + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { PreferenceHeader(text = stringResource(id = R.string.global)) ListPreferenceWidget( @@ -100,8 +111,8 @@ class PreferencesScreen : AndroidScreen() { ) ListPreferenceWidget( - preference = loadedState.themeColourScheme.second, - selectedKey = loadedState.themeColourScheme.first, + preference = loadedState.themeColorScheme.second, + selectedKey = loadedState.themeColorScheme.first, entries = mapOf( "readrops" to stringResource(id = R.string.theme_readrops), "blackwhite" to stringResource(id = R.string.theme_blackwhite), @@ -157,6 +168,24 @@ class PreferencesScreen : AndroidScreen() { PreferenceHeader(text = stringResource(id = R.string.timeline)) + SwitchPreferenceWidget( + preference = loadedState.syncAtLaunchPref.second, + isChecked = loadedState.syncAtLaunchPref.first, + title = stringResource(R.string.synchronize_at_launch) + ) + + ListPreferenceWidget( + preference = loadedState.mainFilterPref.second, + selectedKey = loadedState.mainFilterPref.first, + entries = mapOf( + "ALL" to stringResource(R.string.articles), + "NEW" to stringResource(R.string.new_articles), + "STARS" to stringResource(R.string.favorites) + ), + title = stringResource(R.string.default_category), + onValueChange = {} + ) + ListPreferenceWidget( preference = loadedState.timelineItemSize.second, selectedKey = loadedState.timelineItemSize.first, @@ -182,6 +211,30 @@ class PreferencesScreen : AndroidScreen() { title = stringResource(id = R.string.mark_items_read_on_scroll) ) + ListPreferenceWidget( + preference = loadedState.swipeToLeft.second, + selectedKey = loadedState.swipeToLeft.first, + entries = mapOf( + SwipeAction.DISABLED.name to stringResource(R.string.disabled), + SwipeAction.READ.name to stringResource(R.string.mark_read), + SwipeAction.FAVORITE.name to stringResource(R.string.add_to_favorite) + ), + title = stringResource(R.string.swipe_to_left_action), + onValueChange = {} + ) + + ListPreferenceWidget( + preference = loadedState.swipeToRight.second, + selectedKey = loadedState.swipeToRight.first, + entries = mapOf( + SwipeAction.DISABLED.name to stringResource(R.string.disabled), + SwipeAction.READ.name to stringResource(R.string.mark_read), + SwipeAction.FAVORITE.name to stringResource(R.string.add_to_favorite) + ), + title = stringResource(R.string.swipe_to_right_action), + onValueChange = {} + ) + PreferenceHeader(text = stringResource(id = R.string.item_view)) ListPreferenceWidget( @@ -194,6 +247,22 @@ class PreferencesScreen : AndroidScreen() { title = stringResource(id = R.string.open_items_in), onValueChange = {} ) + + SwitchPreferenceWidget( + preference = loadedState.useCustomShareIntentTpl.second, + isChecked = loadedState.useCustomShareIntentTpl.first, + title = stringResource(id = R.string.use_custom_share_intent_tpl), + onValueChanged = screenModel::updateDialog + ) + + if (loadedState.showDialog) { + CustomShareIntentTextWidget( + preference = loadedState.customShareIntentTpl.second, + template = loadedState.customShareIntentTpl.first, + exampleItem = loadedState.exampleItem, + onDismiss = { screenModel.updateDialog(false) }, + ) + } } } } diff --git a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt index d488a4309..34a130d47 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/PreferencesScreenModel.kt @@ -1,9 +1,13 @@ package com.readrops.app.more.preferences +import android.content.Context import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.app.R import com.readrops.app.util.Preference import com.readrops.app.util.Preferences +import com.readrops.db.Database +import com.readrops.db.entities.Item import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine @@ -13,40 +17,78 @@ import kotlinx.coroutines.launch typealias PreferenceState = Pair> class PreferencesScreenModel( + database: Database, + context: Context, preferences: Preferences, dispatcher: CoroutineDispatcher = Dispatchers.IO ) : StateScreenModel(PreferencesScreenState.Loading) { - init { screenModelScope.launch(dispatcher) { - val flows = listOf( - preferences.theme.flow, - preferences.backgroundSynchronization.flow, - preferences.scrollRead.flow, - preferences.hideReadFeeds.flow, - preferences.openLinksWith.flow, - preferences.timelineItemSize.flow, - preferences.themeColourScheme.flow - ) - - combine( - flows - ) { list -> - PreferencesScreenState.Loaded( - themePref = (list[0] as String) to preferences.theme, - backgroundSyncPref = (list[1] as String) to preferences.backgroundSynchronization, - scrollReadPref = (list[2] as Boolean) to preferences.scrollRead, - hideReadFeeds = (list[3] as Boolean) to preferences.hideReadFeeds, - openLinksWith = (list[4] as String) to preferences.openLinksWith, - timelineItemSize = (list[5] as String) to preferences.timelineItemSize, - themeColourScheme = (list[6] as String) to preferences.themeColourScheme + with(preferences) { + val flows = listOf( + theme.flow, + backgroundSynchronization.flow, + scrollRead.flow, + hideReadFeeds.flow, + openLinksWith.flow, + timelineItemSize.flow, + mainFilter.flow, + synchAtLaunch.flow, + useCustomShareIntentTpl.flow, + customShareIntentTpl.flow, + swipeToLeft.flow, + swipeToRight.flow, + themeColorScheme.flow ) - }.collect { theme -> - mutableState.update { theme } + + combine( + flows + ) { list -> + PreferencesScreenState.Loaded( + themePref = (list[0] as String) to theme, + backgroundSyncPref = (list[1] as String) to backgroundSynchronization, + scrollReadPref = (list[2] as Boolean) to scrollRead, + hideReadFeeds = (list[3] as Boolean) to hideReadFeeds, + openLinksWith = (list[4] as String) to openLinksWith, + timelineItemSize = (list[5] as String) to timelineItemSize, + mainFilterPref = (list[6] as String) to mainFilter, + syncAtLaunchPref = (list[7] as Boolean) to synchAtLaunch, + useCustomShareIntentTpl = (list[8] as Boolean) to useCustomShareIntentTpl, + customShareIntentTpl = (list[9] as String) to customShareIntentTpl, + swipeToLeft = (list[10] as String) to swipeToLeft, + swipeToRight = (list[11] as String) to swipeToRight, + themeColorScheme = (list[12] as String) to themeColorScheme, + exampleItem = if (database.itemDao().count() > 0) { + database.itemDao().selectFirst() + } else { + Item( + title = context.getString(R.string.example_item_title), + author = context.getString(R.string.example_item_author), + content = context.getString(R.string.example_item_content), + link = "https://example.org" + ) + } + ) + }.collect { theme -> + mutableState.update { previous -> + (previous as? PreferencesScreenState.Loaded)?.let { + theme.copy(showDialog = previous.showDialog) + } ?: theme + } + } } } } + fun updateDialog(isVisible: Boolean) { + if (mutableState.value is PreferencesScreenState.Loaded) { + mutableState.update { + (mutableState.value as PreferencesScreenState.Loaded).copy( + showDialog = isVisible + ) + } + } + } } sealed class PreferencesScreenState { @@ -60,7 +102,15 @@ sealed class PreferencesScreenState { val hideReadFeeds: PreferenceState, val openLinksWith: PreferenceState, val timelineItemSize: PreferenceState, - val themeColourScheme: PreferenceState + val mainFilterPref: PreferenceState, + val syncAtLaunchPref: PreferenceState, + val useCustomShareIntentTpl: PreferenceState, + val customShareIntentTpl: PreferenceState, + val swipeToLeft: PreferenceState, + val swipeToRight: PreferenceState, + val themeColorScheme: PreferenceState, + val exampleItem: Item, + val showDialog: Boolean = false ) : PreferencesScreenState() } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt b/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt index 288f4e26e..1d9afe601 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/components/BasePreference.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -22,7 +23,8 @@ fun BasePreference( onClick: () -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, - rightComponent: (@Composable () -> Unit)? = null + rightComponent: (@Composable () -> Unit)? = null, + paddingValues: PaddingValues = PaddingValues(MaterialTheme.spacing.mediumSpacing) ) { Box( modifier = modifier.clickable { onClick() } @@ -30,7 +32,7 @@ fun BasePreference( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + modifier = Modifier.padding(paddingValues) ) { Column( modifier = Modifier.weight(1f) diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/CustomShareIntentTextWidget.kt b/app/src/main/java/com/readrops/app/more/preferences/components/CustomShareIntentTextWidget.kt new file mode 100644 index 000000000..ab77b10b9 --- /dev/null +++ b/app/src/main/java/com/readrops/app/more/preferences/components/CustomShareIntentTextWidget.kt @@ -0,0 +1,166 @@ +package com.readrops.app.more.preferences.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import com.readrops.app.R +import com.readrops.app.util.Preference +import com.readrops.app.util.ShareIntentTextRenderer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.db.entities.Item +import kotlinx.coroutines.launch + +@Composable +fun CustomShareIntentTextWidget( + preference: Preference, + template: String, + exampleItem: Item, + onDismiss: () -> Unit, +) { + var localTemplate by remember { mutableStateOf(template) } + var generateTemplate by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + val renderer = remember { ShareIntentTextRenderer(exampleItem) } + + PreferenceBaseDialog( + title = stringResource(R.string.use_custom_share_intent_tpl), + onDismiss = onDismiss + ) { + Column( + horizontalAlignment = Alignment.Start + ) { + Text( + text = AnnotatedString.fromHtml( + stringResource(R.string.use_custom_share_intent_tpl_explenation), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontStyle = FontStyle.Italic, + color = Color.Blue + ) + ) + ), + style = MaterialTheme.typography.bodyMedium, + color = AlertDialogDefaults.textContentColor + ) + + MediumSpacer() + + TextField( + value = ( + if (generateTemplate) renderer.renderOrError(localTemplate) + else localTemplate + ), + onValueChange = { localTemplate = it }, + readOnly = generateTemplate, + minLines = 3, + modifier = Modifier.focusRequester(focusRequester), + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + ShortSpacer() + + Row(horizontalArrangement = Arrangement.SpaceEvenly) { + TextButton( + modifier = Modifier.weight(1f), + onClick = { + localTemplate = """ + {{ title|remove_author|capitalize }} — {{ author }} + + {{ url }} + """.trimIndent() + }, + ) { + Text(text = stringResource(R.string.try_the_default_template)) + } + + TextButton( + modifier = Modifier.weight(1f), + onClick = { generateTemplate = !generateTemplate }, + ) { + Text( + text = ( + if (generateTemplate) stringResource(R.string.edit_template) + else stringResource(R.string.render_template) + ) + ) + } + } + + MediumSpacer() + + Text( + AnnotatedString.fromHtml( + stringResource( + R.string.example_item_explanation, + renderer.context.keys.joinToString(transform = { "$it" }), + renderer.documentation + ), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontStyle = FontStyle.Italic, + color = Color.Blue + ) + ) + ), + style = MaterialTheme.typography.bodyMedium, + color = AlertDialogDefaults.textContentColor + ) + + MediumSpacer() + + Row(modifier = Modifier.align(Alignment.End)) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.back)) + } + + TextButton( + onClick = { + coroutineScope.launch { + preference.write(localTemplate) + onDismiss() + } + }, + ) { + Text(text = stringResource(id = R.string.save)) + } + } + } + } +} diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt b/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt index 669af0880..482243a60 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/components/RadioButtonPreferenceDialog.kt @@ -6,7 +6,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties import com.readrops.app.R import com.readrops.app.util.theme.LargeSpacer import com.readrops.app.util.theme.MediumSpacer @@ -32,8 +36,12 @@ fun PreferenceBaseDialog( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { + val scrollState = rememberScrollState() + BasicAlertDialog( - onDismissRequest = onDismiss + onDismissRequest = onDismiss, + modifier = Modifier.imePadding(), + properties = DialogProperties(decorFitsSystemWindows = false), ) { Surface( tonalElevation = AlertDialogDefaults.TonalElevation, @@ -44,6 +52,7 @@ fun PreferenceBaseDialog( horizontalAlignment = Alignment.Start, modifier = modifier .padding(MaterialTheme.spacing.largeSpacing) + .verticalScroll(scrollState) ) { Text( text = title, @@ -93,7 +102,7 @@ fun RadioButtonPreferenceDialog( onClick = onDismiss, modifier = Modifier.align(Alignment.End) ) { - Text(text = stringResource(id = R.string.cancel)) + Text(text = stringResource(id = R.string.back)) } } } diff --git a/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt b/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt index 1e36b5c0a..48c398bb7 100644 --- a/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt +++ b/app/src/main/java/com/readrops/app/more/preferences/components/SwitchPreferenceWidget.kt @@ -14,25 +14,25 @@ fun SwitchPreferenceWidget( title: String, modifier: Modifier = Modifier, subtitle: String? = null, + onValueChanged: ((Boolean) -> Unit)? = null ) { val coroutineScope = rememberCoroutineScope() + fun changeValue(newValue: Boolean) { + coroutineScope.launch { + preference.write(newValue) + onValueChanged?.let { it(newValue) } + } + } + BasePreference( title = title, subtitle = subtitle, - onClick = { - coroutineScope.launch { - preference.write(!isChecked) - } - }, + onClick = { changeValue(!isChecked) }, rightComponent = { Switch( checked = isChecked, - onCheckedChange = { - coroutineScope.launch { - preference.write(!isChecked) - } - } + onCheckedChange = ::changeValue ) }, modifier = modifier diff --git a/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt b/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt index de74cbf7b..9d12d7856 100644 --- a/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/readrops/app/notifications/NotificationsScreen.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -60,7 +60,7 @@ class NotificationsScreen(val account: Account) : AndroidScreen() { override fun Content() { val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current - val screenModel = getScreenModel { parametersOf(account) } + val screenModel = koinScreenModel { parametersOf(account) } val state by screenModel.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt index 62c1a2402..37d15cf1d 100644 --- a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt @@ -25,7 +25,7 @@ class FeverRepository( val authenticated = feverDataSource.login(account.login!!, account.password!!) if (authenticated) { - account.displayedName = account.accountType!!.name + account.displayedName = account.type!!.name } else { throw LoginFailedException() } diff --git a/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt b/app/src/main/java/com/readrops/app/repositories/GReaderRepository.kt similarity index 91% rename from app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt rename to app/src/main/java/com/readrops/app/repositories/GReaderRepository.kt index eb269488c..ceef02fe4 100644 --- a/app/src/main/java/com/readrops/app/repositories/FreshRSSRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/GReaderRepository.kt @@ -2,8 +2,8 @@ package com.readrops.app.repositories import com.readrops.api.services.Credentials import com.readrops.api.services.SyncType -import com.readrops.api.services.freshrss.FreshRSSDataSource -import com.readrops.api.services.freshrss.FreshRSSSyncData +import com.readrops.api.services.greader.GReaderDataSource +import com.readrops.api.services.greader.GReaderSyncData import com.readrops.api.utils.AuthInterceptor import com.readrops.app.util.Utils import com.readrops.db.Database @@ -13,16 +13,18 @@ import com.readrops.db.entities.Item import com.readrops.db.entities.ItemState import com.readrops.db.entities.account.Account import org.koin.core.component.KoinComponent +import org.koin.core.component.get -class FreshRSSRepository( +class GReaderRepository( database: Database, account: Account, - private val dataSource: FreshRSSDataSource, + private val dataSource: GReaderDataSource, ) : BaseRepository(database, account), KoinComponent { override suspend fun login(account: Account) { - val authInterceptor = getKoin().get() - authInterceptor.credentials = Credentials.toCredentials(account) + val authInterceptor = get().apply { + credentials = Credentials.toCredentials(account) + } account.token = dataSource.login(account.login!!, account.password!!) // we got the authToken, time to provide it to make real calls @@ -43,7 +45,7 @@ class FreshRSSRepository( val itemStateChanges = database.itemStateChangeDao() .selectItemStateChanges(account.id) - val syncData = FreshRSSSyncData( + val syncData = GReaderSyncData( readIds = itemStateChanges.filter { it.readChange && it.read } .map { it.remoteId }, unreadIds = itemStateChanges.filter { it.readChange && !it.read } @@ -95,7 +97,7 @@ class FreshRSSRepository( onUpdate(newFeed) try { - dataSource.createFeed(account.writeToken!!, newFeed.url!!) + dataSource.createFeed(account.writeToken!!, newFeed.url!!, newFeed.remoteFolderId) } catch (e: Exception) { errors[newFeed] = e } @@ -116,7 +118,7 @@ class FreshRSSRepository( override suspend fun updateFolder(folder: Folder) { dataSource.updateFolder(account.writeToken!!, folder.remoteId!!, folder.name!!) - folder.remoteId = FreshRSSDataSource.FOLDER_PREFIX + folder.name + folder.remoteId = GReaderDataSource.FOLDER_PREFIX + folder.name super.updateFolder(folder) } @@ -185,7 +187,7 @@ class FreshRSSRepository( database.itemStateDao().deleteItemStates(account.id) database.itemStateDao().insert(unreadIds.map { id -> - val starred = starredIds.count { starredId -> starredId == id } == 1 + val starred = starredIds.any { starredId -> starredId == id } if (starred) { starredIds.remove(id) @@ -201,7 +203,7 @@ class FreshRSSRepository( }) database.itemStateDao().insert(readIds.map { id -> - val starred = starredIds.count { starredId -> starredId == id } == 1 + val starred = starredIds.any { starredId -> starredId == id } if (starred) { starredIds.remove(id) } diff --git a/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt index 4127e2ec8..c70d38039 100644 --- a/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt +++ b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt @@ -3,6 +3,7 @@ package com.readrops.app.repositories import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder +import com.readrops.db.entities.OpenIn import com.readrops.db.filters.MainFilter import com.readrops.db.queries.FeedUnreadCountQueryBuilder import com.readrops.db.queries.FoldersAndFeedsQueryBuilder @@ -19,7 +20,7 @@ class GetFoldersWithFeeds( useSeparateState: Boolean, hideReadFeeds: Boolean = false ): Flow>> { - val foldersAndFeedsQuery = FoldersAndFeedsQueryBuilder.build(accountId, hideReadFeeds) + val foldersAndFeedsQuery = FoldersAndFeedsQueryBuilder.build(accountId, mainFilter, hideReadFeeds, useSeparateState) val unreadItemsCountQuery = FeedUnreadCountQueryBuilder.build(accountId, mainFilter, useSeparateState) return combine( @@ -44,9 +45,17 @@ class GetFoldersWithFeeds( id = it.feedId, name = it.feedName, iconUrl = it.feedIcon, + color = it.feedColor, + imageUrl = it.feedImage, url = it.feedUrl, siteUrl = it.feedSiteUrl, description = it.feedDescription, + isNotificationEnabled = it.feedNotificationsEnabled, + openIn = if (it.feedOpenIn != null) { + it.feedOpenIn!! + } else { + OpenIn.LOCAL_VIEW + }, remoteId = it.feedRemoteId, unreadCount = itemCounts[it.feedId] ?: 0 ) @@ -60,7 +69,13 @@ class GetFoldersWithFeeds( } } - foldersWithFeeds.toSortedMap(nullsLast(Folder::compareTo)) + // Nextcloud News case, no need to add a config parameter + val comparator = compareByDescending { + it?.name?.startsWith("_") + } + .then(nullsLast(Folder::compareTo)) + + foldersWithFeeds.toSortedMap(comparator) } } diff --git a/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt b/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt index 032de40fd..0efbaf4fb 100644 --- a/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/LocalRSSRepository.kt @@ -10,17 +10,24 @@ import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Item import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import okhttp3.Headers import org.jsoup.Jsoup import org.koin.core.component.KoinComponent import org.koin.core.component.get +class FeedExistException : Exception() + class LocalRSSRepository( private val dataSource: LocalRSSDataSource, database: Database, - account: Account + account: Account, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : BaseRepository(database, account), KoinComponent { override suspend fun login(account: Account) { /* useless here */ } @@ -28,7 +35,7 @@ class LocalRSSRepository( override suspend fun synchronize( selectedFeeds: List, onUpdate: suspend (Feed) -> Unit - ): Pair { + ): Pair = withContext(dispatcher) { val errors = hashMapOf() val newItems = mutableListOf() @@ -36,27 +43,35 @@ class LocalRSSRepository( database.feedDao().selectFeeds(account.id) } - for (feed in feeds) { - onUpdate(feed) - - val headers = Headers.Builder() - if (feed.etag != null) { - headers[ApiUtils.IF_NONE_MATCH_HEADER] = feed.etag!! - } - if (feed.lastModified != null) { - headers[ApiUtils.IF_MODIFIED_HEADER] = feed.lastModified!! - } - - try { - val pair = dataSource.queryRSSResource(feed.url!!, headers.build()) - - pair?.let { newItems.addAll(insertNewItems(it.second, feed)) } - } catch (e: Exception) { - errors[feed] = e + feeds.chunked(MAX_PARALLEL_REQUESTS) + .map { mapFeeds -> + mapFeeds.map { feed -> + async { + ensureActive() + + val headers = Headers.Builder() + if (feed.etag != null) { + headers[ApiUtils.IF_NONE_MATCH_HEADER] = feed.etag!! + } + if (feed.lastModified != null) { + headers[ApiUtils.IF_MODIFIED_HEADER] = feed.lastModified!! + } + + try { + val pair = dataSource.queryRSSResource(feed.url!!, headers.build()) + + pair?.let { newItems.addAll(insertNewItems(it.second, feed)) } + } catch (e: Exception) { + errors[feed] = e + } + + onUpdate(feed) + } + } + .awaitAll() } - } - return Pair(SyncResult(items = newItems), errors) + SyncResult(items = newItems) to errors } override suspend fun synchronize(): SyncResult = @@ -66,21 +81,29 @@ class LocalRSSRepository( override suspend fun insertNewFeeds( newFeeds: List, onUpdate: (Feed) -> Unit - ): ErrorResult = withContext(Dispatchers.IO) { + ): ErrorResult = withContext(dispatcher) { val errors = hashMapOf() - for (newFeed in newFeeds) { - onUpdate(newFeed) - - try { - val result = dataSource.queryRSSResource(newFeed.url!!, null)!! - insertFeed(result.first.also { it.folderId = newFeed.folderId }) - } catch (e: Exception) { - errors[newFeed] = e + newFeeds.chunked(MAX_PARALLEL_REQUESTS) + .map { newFeedsMap -> + newFeedsMap.map { newFeed -> + async { + ensureActive() + + try { + val result = dataSource.queryRSSResource(newFeed.url!!, null)!! + insertFeed(result.first.also { it.folderId = newFeed.folderId }) + } catch (e: Exception) { + errors[newFeed] = e + } + + onUpdate(newFeed) + } + } + .awaitAll() } - } - return@withContext errors + errors } private suspend fun insertNewItems(items: List, feed: Feed): List { @@ -89,7 +112,7 @@ class LocalRSSRepository( for (item in items) { if (!database.itemDao().itemExists(item.remoteId!!, feed.accountId)) { if (item.description != null) { - item.cleanDescription = Jsoup.parse(item.description).text() + item.cleanDescription = Jsoup.parse(item.description!!).text() } if (item.content != null) { @@ -103,6 +126,8 @@ class LocalRSSRepository( } } + // sort by date + newItems.sort() database.itemDao().insert(newItems) .zip(newItems) .forEach { (id, item) -> item.id = id.toInt() } @@ -111,9 +136,8 @@ class LocalRSSRepository( } private suspend fun insertFeed(feed: Feed): Feed { - // TODO better handle this case - require(!database.feedDao().feedExists(feed.url!!, account.id)) { - "Feed already exists for account ${account.accountName}" + if (database.feedDao().feedExists(feed.url!!, account.id)) { + throw FeedExistException() } return feed.apply { @@ -123,14 +147,27 @@ class LocalRSSRepository( lastModified = null try { - iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl -> + val document = HtmlParser.getHTMLHeadFromUrl(siteUrl!!, get()) + if (imageUrl == null) { + imageUrl = HtmlParser.getFeedImage(document) + } + + if (description == null) { + description = HtmlParser.getFeedDescription(document) + } + + iconUrl = HtmlParser.getFaviconLink(document).also { feedUrl -> feedUrl?.let { color = FeedColors.getFeedColor(it) } } } catch (e: Exception) { - Log.d("LocalRSSRepository", "insertFeed: ${e.message}") + Log.e("LocalRSSRepository", "getFaviconLink: ${e.message}") } id = database.feedDao().insert(this).toInt() } } + + companion object { + const val MAX_PARALLEL_REQUESTS = 30 + } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt b/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt index f695a23cc..cf6685718 100644 --- a/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/NextcloudNewsRepository.kt @@ -94,8 +94,12 @@ class NextcloudNewsRepository( onUpdate(newFeed) try { - val feeds = dataSource.createFeed(newFeed.url!!, null) - insertFeeds(feeds) + dataSource.createFeed(newFeed.url!!, newFeed.remoteFolderId?.toInt()) + .onEach { + it.accountId = account.id + it.folderId = newFeed.folderId + } + .run { database.feedDao().insert(this) } } catch (e: Exception) { errors[newFeed] = e } @@ -162,7 +166,7 @@ class NextcloudNewsRepository( itemsFeedsIds[item.feedRemoteId] = feedId } - if (!initialSync && feedId > 0 && database.itemDao().itemExists(item.remoteId!!, feedId)) { + if (!initialSync && feedId > 0 && database.itemDao().itemExists(item.remoteId!!, account.id)) { database.itemDao() .updateReadAndStarState(item.remoteId!!, item.isRead, item.isStarred) continue diff --git a/app/src/main/java/com/readrops/app/repositories/Repository.kt b/app/src/main/java/com/readrops/app/repositories/Repository.kt index ae34508b4..447a1ed8f 100644 --- a/app/src/main/java/com/readrops/app/repositories/Repository.kt +++ b/app/src/main/java/com/readrops/app/repositories/Repository.kt @@ -1,5 +1,6 @@ package com.readrops.app.repositories +import androidx.room.withTransaction import com.readrops.api.services.fever.adapters.Favicon import com.readrops.db.Database import com.readrops.db.entities.Feed @@ -70,49 +71,100 @@ abstract class BaseRepository( open suspend fun deleteFolder(folder: Folder) = database.folderDao().delete(folder) open suspend fun setItemReadState(item: Item) { - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, true) - database.itemStateDao().upsertItemReadState( - ItemState( - id = 0, - read = item.isRead, - starred = item.isStarred, - remoteId = item.remoteId!!, - accountId = account.id + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, true) + database.itemStateDao().upsertItemReadState( + ItemState( + id = 0, + read = item.isRead, + starred = item.isStarred, + remoteId = item.remoteId!!, + accountId = account.id + ) ) - ) - } - account.isLocal -> { - database.itemDao().updateReadState(item.id, item.isRead) - } - else -> { - database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, false) - database.itemDao().updateReadState(item.id, item.isRead) + } + + account.isLocal -> { + database.itemDao().updateReadState(item.id, item.isRead) + } + + else -> { + database.itemStateChangeDao().upsertItemReadStateChange(item, account.id, false) + database.itemDao().updateReadState(item.id, item.isRead) + } } } } open suspend fun setItemStarState(item: Item) { - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, true) - database.itemStateDao().upsertItemStarState( - ItemState( - id = 0, - read = item.isRead, - starred = item.isStarred, - remoteId = item.remoteId!!, - accountId = account.id + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, true) + database.itemStateDao().upsertItemStarState( + ItemState( + id = 0, + read = item.isRead, + starred = item.isStarred, + remoteId = item.remoteId!!, + accountId = account.id + ) ) - ) - } - account.isLocal -> { - database.itemDao().updateStarState(item.id, item.isStarred) + } + + account.isLocal -> { + database.itemDao().updateStarState(item.id, item.isStarred) + } + + else -> { + database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, false) + database.itemDao().updateStarState(item.id, item.isStarred) + } } - else -> { - database.itemStateChangeDao().upsertItemStarStateChange(item, account.id, false) - database.itemDao().updateStarState(item.id, item.isStarred) + } + } + + open suspend fun setItemsRead(items: List) { + require(items.all { it.isRead == false }) { + "Do not add an item state change for an item which is already read" + } + + val accountId = account.id + val ids = items.map { it.id } + + database.withTransaction { + when { + account.config.useSeparateState -> { + items.forEach { + database.itemStateChangeDao().upsertItemReadStateChange(it, accountId, true) + } + + database.itemStateDao().setItemsRead( + ids = items.map { it.remoteId!! }, + itemStates = items.map { + ItemState( + read = true, + remoteId = it.remoteId!!, + accountId = accountId + ) + }, + accountId = accountId + ) + } + + account.isLocal -> { + database.itemDao().setAllItemsRead(ids) + } + + else -> { + items.forEach { + database.itemStateChangeDao() + .upsertItemReadStateChange(it, accountId, false) + } + database.itemDao().setAllItemsRead(ids) + } } } } @@ -120,17 +172,21 @@ abstract class BaseRepository( open suspend fun setAllItemsRead() { val accountId = account.id - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) - database.itemStateDao().setAllItemsRead(accountId) - } - account.isLocal -> { - database.itemDao().setAllItemsRead(account.id) - } - else -> { - database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) - database.itemDao().setAllItemsRead(accountId) + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) + database.itemStateDao().setAllItemsRead(accountId) + } + + account.isLocal -> { + database.itemDao().setAllItemsRead(account.id) + } + + else -> { + database.itemStateChangeDao().upsertAllItemsReadStateChanges(accountId) + database.itemDao().setAllItemsRead(accountId) + } } } } @@ -138,17 +194,21 @@ abstract class BaseRepository( open suspend fun setAllStarredItemsRead() { val accountId = account.id - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) - database.itemStateDao().setAllStarredItemsRead(accountId) - } - account.isLocal -> { - database.itemDao().setAllStarredItemsRead(accountId) - } - else -> { - database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) - database.itemDao().setAllStarredItemsRead(accountId) + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) + database.itemStateDao().setAllStarredItemsRead(accountId) + } + + account.isLocal -> { + database.itemDao().setAllStarredItemsRead(accountId) + } + + else -> { + database.itemStateChangeDao().upsertStarredItemReadStateChanges(accountId) + database.itemDao().setAllStarredItemsRead(accountId) + } } } } @@ -156,17 +216,21 @@ abstract class BaseRepository( open suspend fun setAllNewItemsRead() { val accountId = account.id - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) - database.itemStateDao().setAllNewItemsRead(accountId) - } - account.isLocal -> { - database.itemDao().setAllNewItemsRead(accountId) - } - else -> { - database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) - database.itemDao().setAllNewItemsRead(accountId) + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) + database.itemStateDao().setAllNewItemsRead(accountId) + } + + account.isLocal -> { + database.itemDao().setAllNewItemsRead(accountId) + } + + else -> { + database.itemStateChangeDao().upsertNewItemReadStateChanges(accountId) + database.itemDao().setAllNewItemsRead(accountId) + } } } } @@ -174,18 +238,23 @@ abstract class BaseRepository( open suspend fun setAllItemsReadByFeed(feedId: Int) { val accountId = account.id - when { - account.config.useSeparateState -> { - database.itemStateChangeDao() - .upsertItemReadStateChangesByFeed(feedId, accountId) - database.itemStateDao().setAllItemsReadByFeed(feedId, accountId) - } - account.isLocal -> { - database.itemDao().setAllItemsReadByFeed(feedId, accountId) - } - else -> { - database.itemStateChangeDao().upsertItemReadStateChangesByFeed(feedId, accountId) - database.itemDao().setAllItemsReadByFeed(feedId, accountId) + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao() + .upsertItemReadStateChangesByFeed(feedId, accountId) + database.itemStateDao().setAllItemsReadByFeed(feedId, accountId) + } + + account.isLocal -> { + database.itemDao().setAllItemsReadByFeed(feedId, accountId) + } + + else -> { + database.itemStateChangeDao() + .upsertItemReadStateChangesByFeed(feedId, accountId) + database.itemDao().setAllItemsReadByFeed(feedId, accountId) + } } } } @@ -193,17 +262,23 @@ abstract class BaseRepository( open suspend fun setAllItemsReadByFolder(folderId: Int) { val accountId = account.id - when { - account.config.useSeparateState -> { - database.itemStateChangeDao().upsertItemReadStateChangesByFolder(folderId, accountId) - database.itemStateDao().setAllItemsReadByFolder(folderId, accountId) - } - account.isLocal -> { - database.itemDao().setAllItemsReadByFolder(folderId, accountId) - } - else -> { - database.itemStateChangeDao().upsertItemReadStateChangesByFolder(folderId, accountId) - database.itemDao().setAllItemsReadByFolder(folderId, accountId) + database.withTransaction { + when { + account.config.useSeparateState -> { + database.itemStateChangeDao() + .upsertItemReadStateChangesByFolder(folderId, accountId) + database.itemStateDao().setAllItemsReadByFolder(folderId, accountId) + } + + account.isLocal -> { + database.itemDao().setAllItemsReadByFolder(folderId, accountId) + } + + else -> { + database.itemStateChangeDao() + .upsertItemReadStateChangesByFolder(folderId, accountId) + database.itemDao().setAllItemsReadByFolder(folderId, accountId) + } } } } @@ -213,6 +288,7 @@ abstract class BaseRepository( onUpdate: (Feed) -> Unit ): ErrorResult { val errors = hashMapOf() + val feedsToInsert = arrayListOf() for ((folder, feeds) in foldersAndFeeds) { if (folder != null) { @@ -220,21 +296,18 @@ abstract class BaseRepository( val dbFolder = database.folderDao().selectFolderByName(folder.name!!, account.id) - if (dbFolder != null) { - folder.id = dbFolder.id - } else { - folder.id = database.folderDao().insert(folder).toInt() - } + folder.id = dbFolder?.id ?: database.folderDao().insert(folder).toInt() } feeds.forEach { it.folderId = folder?.id } - - errors += insertNewFeeds( - newFeeds = feeds, - onUpdate = onUpdate - ) + feedsToInsert += feeds } + errors += insertNewFeeds( + newFeeds = feedsToInsert, + onUpdate = onUpdate + ) + return errors } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt b/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt index d7749ed34..557afef60 100644 --- a/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt +++ b/app/src/main/java/com/readrops/app/sync/SyncAnalyzer.kt @@ -4,15 +4,15 @@ import android.content.Context import android.graphics.Bitmap import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap -import coil.imageLoader -import coil.request.ImageRequest +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.toBitmap import com.readrops.app.R import com.readrops.app.repositories.SyncResult import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Item import com.readrops.db.entities.account.Account -import org.koin.core.component.KoinComponent data class NotificationContent( val title: String? = null, @@ -26,17 +26,14 @@ data class NotificationContent( class SyncAnalyzer( val context: Context, val database: Database -) : KoinComponent { +) { suspend fun getNotificationContent(syncResults: Map): NotificationContent? { return if (newItemsInMultipleAccounts(syncResults)) { // new items from several accounts - val feeds = database.feedDao().selectFromIds(getFeedsIdsForNewItems(syncResults)) + val feeds = database.feedDao().selectFromIds(getNewItemsFeedIds(syncResults)) - var itemCount = 0 - for (syncResult in syncResults.values) { - itemCount += syncResult.items.filter { - isFeedNotificationEnabledForItem(feeds, it) - }.size + val itemCount = syncResults.values.sumOf { + it.items.count { isFeedNotificationEnabledForItem(feeds, it) } } NotificationContent(title = context.getString(R.string.new_items, "$itemCount")) @@ -54,31 +51,28 @@ class SyncAnalyzer( account: Account, syncResult: SyncResult ): NotificationContent? { - val feedsIdsForNewItems = getFeedsIdsForNewItems(syncResult) - if (account.isNotificationsEnabled) { - val feeds = database.feedDao().selectFromIds(feedsIdsForNewItems) + val feedIds = getNewItemsFeedIds(syncResult) + val feeds = database.feedDao().selectFromIds(feedIds) - val items = - syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) } + val items = syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) } val itemCount = items.size return when { // multiple new items from several feeds - feedsIdsForNewItems.size > 1 && itemCount > 1 -> { + feedIds.size > 1 && itemCount > 1 -> { NotificationContent( - title = account.accountName!!, + title = account.name!!, text = context.getString(R.string.new_items, itemCount.toString()), largeIcon = ContextCompat.getDrawable( context, - account.accountType!!.iconRes + account.type!!.iconRes )!!.toBitmap(), accountId = account.id ) } // multiple new items from a single feed - feedsIdsForNewItems.size == 1 -> - singleFeedCase(feedsIdsForNewItems.first(), syncResult.items, account) + feedIds.size == 1 -> singleFeedCase(feedIds.first(), syncResult.items, account) // only one new item from a single feed itemCount == 1 -> singleFeedCase(items.first().feedId, items, account) else -> null @@ -95,7 +89,7 @@ class SyncAnalyzer( ): NotificationContent? { val feed = database.feedDao().selectFeed(feedId) - if (feed.isNotificationEnabled) { + return if (feed.isNotificationEnabled) { val icon = feed.iconUrl?.let { val target = context.imageLoader .execute( @@ -104,7 +98,7 @@ class SyncAnalyzer( .build() ) - target.drawable?.toBitmap() + target.image?.toBitmap() } val (item, text) = if (items.size == 1) { @@ -114,7 +108,7 @@ class SyncAnalyzer( null to context.getString(R.string.new_items, items.size.toString()) } - return NotificationContent( + NotificationContent( title = feed.name, text = text, largeIcon = icon, @@ -122,41 +116,28 @@ class SyncAnalyzer( color = feed.color, accountId = account.id ) + } else { + null } - - return null } + /** + * Return true if at least two accounts have new items and notifications enabled + */ private fun newItemsInMultipleAccounts(syncResults: Map): Boolean { - val itemsNotEmptyByAccount = mutableListOf() - - for ((account, syncResult) in syncResults) { - if (account.isNotificationsEnabled) { - itemsNotEmptyByAccount += syncResult.items.isNotEmpty() - } - } - - // return true it there is at least two true in the list - return (itemsNotEmptyByAccount.groupingBy { it }.eachCount()[true] ?: 0) > 1 + return (syncResults.filter { it.key.isNotificationsEnabled } + .map { it.value.items.isNotEmpty() } + .groupingBy { it } + .eachCount()[true] ?: 0) > 1 } - private fun getFeedsIdsForNewItems(syncResult: SyncResult): List { - val feedsIds = mutableListOf() - - syncResult.items.forEach { - if (it.feedId !in feedsIds) - feedsIds += it.feedId - } + private fun getNewItemsFeedIds(syncResult: SyncResult): List = + syncResult.items.map { it.feedId } + .distinct() - return feedsIds - } - - private fun getFeedsIdsForNewItems(syncResults: Map): List { - val feedsIds = mutableListOf() - - syncResults.values.forEach { feedsIds += getFeedsIdsForNewItems(it) } - return feedsIds - } + private fun getNewItemsFeedIds(syncResults: Map): List = + syncResults.values.map { getNewItemsFeedIds(it) } + .flatten() private fun isFeedNotificationEnabledForItem(feeds: List, item: Item): Boolean = feeds.find { it.id == item.feedId }?.isNotificationEnabled!! diff --git a/app/src/main/java/com/readrops/app/sync/SyncWorker.kt b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt index 1db5a5822..74abd6660 100644 --- a/app/src/main/java/com/readrops/app/sync/SyncWorker.kt +++ b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt @@ -3,8 +3,6 @@ package com.readrops.app.sync import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.content.SharedPreferences -import android.graphics.BitmapFactory import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.Action @@ -23,27 +21,17 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf -import coil.annotation.ExperimentalCoilApi -import coil.imageLoader -import com.readrops.api.services.Credentials -import com.readrops.api.services.fever.adapters.Favicon -import com.readrops.api.utils.AuthInterceptor import com.readrops.app.MainActivity import com.readrops.app.R import com.readrops.app.ReadropsApp -import com.readrops.app.repositories.BaseRepository -import com.readrops.app.repositories.ErrorResult import com.readrops.app.repositories.SyncResult -import com.readrops.app.util.FeedColors -import com.readrops.app.util.putSerializable +import com.readrops.app.util.extensions.putSerializable import com.readrops.db.Database -import com.readrops.db.entities.Feed import com.readrops.db.entities.account.Account import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf import java.util.concurrent.TimeUnit @@ -64,9 +52,7 @@ class SyncWorker( if (infos.any { it.state == WorkInfo.State.RUNNING && it.id != id }) { return if (isManual) { Result.failure( - workDataOf( - SYNC_FAILURE_KEY to true, - ) + workDataOf(SYNC_FAILURE_KEY to true) .putSerializable( SYNC_FAILURE_EXCEPTION_KEY, Exception(applicationContext.getString(R.string.background_sync_already_running)) @@ -85,14 +71,37 @@ class SyncWorker( .setOnlyAlertOnce(true) return try { - val (workResult, syncResults) = refreshAccounts(notificationBuilder) + val synchronizer = get() + + val (syncResults, errorResult) = synchronizer.synchronizeAccounts( + notificationBuilder = notificationBuilder, + inputData = SyncInputData( + accountId = inputData.getInt(ACCOUNT_ID_KEY, -1), + feedId = inputData.getInt(FEED_ID_KEY, -1), + folderId = inputData.getInt(FOLDER_ID_KEY, -1) + ), + onUpdate = { feed, feedMax, feedCount -> + setProgress( + workDataOf( + FEED_NAME_KEY to feed.name, + FEED_MAX_KEY to feedMax, + FEED_COUNT_KEY to feedCount + ) + ) + } + ) + notificationManager.cancel(SYNC_NOTIFICATION_ID) if (!isManual) { displaySyncResults(syncResults) } - workResult + return Result.success(workDataOf(END_SYNC_KEY to true).apply { + if (errorResult.isNotEmpty() && isManual) { + putSerializable(LOCAL_SYNC_ERRORS_KEY, errorResult) + } + }) } catch (e: Exception) { Log.e(TAG, "${e.printStackTrace()}") @@ -108,190 +117,8 @@ class SyncWorker( } } - private suspend fun refreshAccounts(notificationBuilder: Builder): Pair> { - val sharedPreferences = get() - var workResult = Result.success(workDataOf(END_SYNC_KEY to true)) - val syncResults = mutableMapOf() - - val accountId = inputData.getInt(ACCOUNT_ID_KEY, -1) - val accounts = if (accountId == -1) { - database.accountDao().selectAllAccounts().first() - } else { - listOf(database.accountDao().select(accountId)) - } - - for (account in accounts) { - if (!account.isLocal) { - account.login = sharedPreferences.getString(account.loginKey, null) - account.password = sharedPreferences.getString(account.passwordKey, null) - } - - val repository = get { parametersOf(account) } - - notificationBuilder.setContentTitle( - applicationContext.resources.getString( - R.string.updating_account, - account.accountName - ) - ) - - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) - } - - if (account.isLocal) { - val result = refreshLocalAccount(repository, account, notificationBuilder) - - if (result.second.isNotEmpty() && tags.contains(WORK_MANUAL)) { - workResult = Result.success( - workDataOf(END_SYNC_KEY to true) - .putSerializable(LOCAL_SYNC_ERRORS_KEY, result.second) - ) - } - - syncResults[account] = result.first - } else { - get().credentials = Credentials.toCredentials(account) - val syncResult = repository.synchronize() - - if (syncResult.favicons.isNotEmpty()) { - loadFeverFavicons(syncResult.favicons, account, notificationBuilder) - } else { - fetchFeedColors(syncResult, notificationBuilder) - } - - syncResults[account] = syncResult - } - } - - return workResult to syncResults - } - - private suspend fun refreshLocalAccount( - repository: BaseRepository, - account: Account, - notificationBuilder: Builder - ): Pair { - val feedId = inputData.getInt(FEED_ID_KEY, 0) - val folderId = inputData.getInt(FOLDER_ID_KEY, 0) - - val feeds = when { - feedId > 0 -> listOf(database.feedDao().selectFeed(feedId)) - folderId > 0 -> database.feedDao().selectFeedsByFolder(folderId) - else -> listOf() - } - - var feedCount = 0 - val feedMax = if (feeds.isNotEmpty()) { - feeds.size - } else { - database.feedDao().selectFeedCount(account.id) - } - - val result = repository.synchronize( - selectedFeeds = feeds, - onUpdate = { feed -> - if (notificationManager.areNotificationsEnabled()) { - notificationBuilder.setContentText(feed.name) - .setStyle(NotificationCompat.BigTextStyle().bigText(feed.name)) - .setProgress(feedMax, ++feedCount, false) - - notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) - } - - setProgress( - workDataOf( - FEED_NAME_KEY to feed.name, - FEED_MAX_KEY to feedMax, - FEED_COUNT_KEY to feedCount - ) - ) - } - ) - - if (result.second.isNotEmpty()) { - Log.e( - TAG, - "refreshing local account ${account.accountName}: ${result.second.size} errors" - ) - } - - return result - } - - private suspend fun fetchFeedColors( - syncResult: SyncResult, - notificationBuilder: Builder - ) = with(syncResult) { - notificationBuilder.setContentTitle(applicationContext.getString(R.string.get_feeds_colors)) - - for ((index, feed) in feeds.withIndex()) { - notificationBuilder.setContentText(feed.name) - .setStyle(NotificationCompat.BigTextStyle().bigText(feed.name)) - .setProgress(feeds.size, index + 1, false) - - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) - } - - try { - if (feed.iconUrl != null) { - val color = FeedColors.getFeedColor(feed.iconUrl!!) - database.feedDao().updateFeedColor(feed.id, color) - } - } catch (e: Exception) { - Log.e(TAG, "${feed.name}: ${e.message}") - } - } - } - - @OptIn(ExperimentalCoilApi::class) - private suspend fun loadFeverFavicons( - favicons: Map, - account: Account, - notificationBuilder: Builder - ) { - if (notificationManager.areNotificationsEnabled()) { - // can't make detailed progress as the favicon might already exist in cache - notificationBuilder.setContentTitle("Loading icons and colors") - .setProgress(0, 0, true) - notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) - } - - val diskCache = applicationContext.imageLoader.diskCache!! - - for ((feed, favicon) in favicons) { - val key = "account_${account.id}_feed_${feed.name!!.replace(" ", "_")}" - val snapshot = diskCache.openSnapshot(key) - - if (snapshot == null) { - try { - diskCache.openEditor(key)!!.apply { - diskCache.fileSystem.write(data) { - write(favicon.data) - } - commit() - } - - database.feedDao().updateFeedIconUrl(feed.id, key) - val bitmap = - BitmapFactory.decodeByteArray(favicon.data, 0, favicon.data.size) - - if (bitmap != null) { - val color = FeedColors.getFeedColor(bitmap) - database.feedDao().updateFeedColor(feed.id, color) - } - } catch (e: Exception) { - Log.e(TAG, "${feed.name}: ${e.message}") - } - } - - snapshot?.close() - } - } - private suspend fun displaySyncResults(syncResults: Map) { - val notificationContent = SyncAnalyzer(applicationContext, database) + val notificationContent = get() .getNotificationContent(syncResults) if (notificationContent != null) { @@ -343,14 +170,12 @@ class SyncWorker( putExtra(ITEM_ID_KEY, itemId) } - val pendingIntent = - PendingIntent.getBroadcast( - applicationContext, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - + val pendingIntent = PendingIntent.getBroadcast( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) return Action.Builder( R.drawable.ic_done_all, @@ -367,13 +192,12 @@ class SyncWorker( putExtra(ITEM_ID_KEY, itemId) } - val pendingIntent = - PendingIntent.getBroadcast( - applicationContext, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = PendingIntent.getBroadcast( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) return Action.Builder( R.drawable.ic_favorite_border, @@ -390,7 +214,7 @@ class SyncWorker( private val WORK_AUTO = "$TAG-auto" private val WORK_MANUAL = "$TAG-manual" - private const val SYNC_NOTIFICATION_ID = 2 + const val SYNC_NOTIFICATION_ID = 2 const val SYNC_RESULT_NOTIFICATION_ID = 3 const val END_SYNC_KEY = "END_SYNC" diff --git a/app/src/main/java/com/readrops/app/sync/Synchronizer.kt b/app/src/main/java/com/readrops/app/sync/Synchronizer.kt new file mode 100644 index 000000000..9cfcdd6cf --- /dev/null +++ b/app/src/main/java/com/readrops/app/sync/Synchronizer.kt @@ -0,0 +1,245 @@ +package com.readrops.app.sync + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Builder +import androidx.core.app.NotificationManagerCompat +import coil3.imageLoader +import com.readrops.api.services.Credentials +import com.readrops.api.services.fever.adapters.Favicon +import com.readrops.api.utils.AuthInterceptor +import com.readrops.app.R +import com.readrops.app.repositories.BaseRepository +import com.readrops.app.repositories.ErrorResult +import com.readrops.app.repositories.SyncResult +import com.readrops.app.sync.SyncWorker.Companion.SYNC_NOTIFICATION_ID +import com.readrops.app.util.FeedColors +import com.readrops.db.Database +import com.readrops.db.entities.Feed +import com.readrops.db.entities.account.Account +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf + +data class SyncInputData( + val accountId: Int, + val feedId: Int, + val folderId: Int +) + +class Synchronizer( + private val notificationManager: NotificationManagerCompat, + private val database: Database, + private val context: Context, + private val encryptedPreferences: SharedPreferences, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : KoinComponent { + + suspend fun synchronizeAccounts( + notificationBuilder: Builder, + inputData: SyncInputData, + onUpdate: suspend (feed: Feed, feedMax: Int, feedCount: Int) -> Unit + ): Pair, ErrorResult> { + val syncResults = mutableMapOf() + val errorResult = hashMapOf() + + val accounts = if (inputData.accountId == -1) { + database.accountDao().selectAllAccounts().first() + } else { + listOf(database.accountDao().select(inputData.accountId)) + } + + for (account in accounts) { + if (!account.isLocal) { + account.login = encryptedPreferences.getString(account.loginKey, null) + account.password = encryptedPreferences.getString(account.passwordKey, null) + } + + val repository = get { parametersOf(account) } + + notificationBuilder.setContentTitle( + context.resources.getString( + R.string.updating_account, + account.name + ) + ) + + if (notificationManager.areNotificationsEnabled()) { + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + } + + if (account.isLocal) { + val result = refreshLocalAccount( + repository = repository, + account = account, + notificationBuilder = notificationBuilder, + inputData = inputData, + onUpdate = onUpdate + ) + + syncResults[account] = result.first + errorResult.putAll(result.second) + } else { + get().credentials = Credentials.toCredentials(account) + val syncResult = repository.synchronize() + + if (syncResult.favicons.isNotEmpty()) { + loadFeverFavicons(syncResult.favicons, account, notificationBuilder) + } else { + fetchFeedColors(syncResult, notificationBuilder) + } + + syncResults[account] = syncResult + } + } + + return syncResults to errorResult + } + + private suspend fun refreshLocalAccount( + repository: BaseRepository, + account: Account, + notificationBuilder: Builder, + inputData: SyncInputData, + onUpdate: suspend (feed: Feed, feedMax: Int, feedCount: Int) -> Unit + ): Pair { + val feedId = inputData.feedId + val folderId = inputData.folderId + + val feeds = when { + feedId > 0 -> listOf(database.feedDao().selectFeed(feedId)) + folderId > 0 -> database.feedDao().selectFeedsByFolder(folderId) + else -> listOf() + } + + var feedCount = 0 + val feedMax = if (feeds.isNotEmpty()) { + feeds.size + } else { + database.feedDao().selectFeedCount(account.id) + } + + val result = repository.synchronize( + selectedFeeds = feeds, + onUpdate = { feed -> + if (notificationManager.areNotificationsEnabled()) { + notificationBuilder.setContentText(feed.name) + .setStyle(NotificationCompat.BigTextStyle().bigText(feed.name)) + .setProgress(feedMax, ++feedCount, false) + + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + } + + onUpdate(feed, feedMax, feedCount) + } + ) + + if (result.second.isNotEmpty()) { + Log.e(TAG, "refreshing local account ${account.name}: ${result.second.size} errors") + } + + return result + } + + private suspend fun fetchFeedColors( + syncResult: SyncResult, + notificationBuilder: Builder + ) = withContext(dispatcher) { + notificationBuilder.setContentTitle(context.getString(R.string.get_feeds_colors)) + + var index = 0 + syncResult.feeds.chunked(MAX_PARALLEL_REQUESTS) + .map { + it.map { feed -> + async { + notificationBuilder.setProgress(syncResult.feeds.size, ++index, false) + + if (notificationManager.areNotificationsEnabled()) { + notificationManager.notify( + SYNC_NOTIFICATION_ID, + notificationBuilder.build() + ) + } + + try { + if (feed.iconUrl != null) { + val color = FeedColors.getFeedColor(feed.iconUrl!!) + database.feedDao().updateFeedColor(feed.id, color) + } + + Unit + } catch (e: Exception) { + Log.e(TAG, "${feed.name}: ${e.message}") + } + } + } + .awaitAll() + } + } + + private suspend fun loadFeverFavicons( + favicons: Map, + account: Account, + notificationBuilder: Builder + ) = withContext(dispatcher) { + if (notificationManager.areNotificationsEnabled()) { + // can't make detailed progress as the favicon might already exist in cache + notificationBuilder.setContentTitle("Loading icons and colors") + .setProgress(0, 0, true) + notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build()) + } + + val diskCache = context.imageLoader.diskCache!! + + favicons.entries.chunked(MAX_PARALLEL_REQUESTS) + .map { + it.map { (feed, favicon) -> + async { + val key = "account_${account.id}_feed_${feed.name!!.replace(" ", "_")}" + val snapshot = diskCache.openSnapshot(key) + + if (snapshot == null) { + try { + diskCache.openEditor(key)!!.apply { + diskCache.fileSystem.write(data) { + write(favicon.data) + } + + commit() + } + + database.feedDao().updateFeedIconUrl(feed.id, key) + val bitmap = + BitmapFactory.decodeByteArray(favicon.data, 0, favicon.data.size) + + if (bitmap != null) { + val color = FeedColors.getFeedColor(bitmap) + database.feedDao().updateFeedColor(feed.id, color) + } + } catch (e: Exception) { + Log.e(TAG, "${feed.name}: ${e.message}") + } + } + + snapshot?.close() + } + } + .awaitAll() + } + } + + companion object { + private val TAG = Synchronizer::class.java.simpleName + + private const val MAX_PARALLEL_REQUESTS = 30 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt b/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt deleted file mode 100644 index 3b4d4399e..000000000 --- a/app/src/main/java/com/readrops/app/timelime/FilterBottomSheet.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.readrops.app.timelime - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.readrops.app.R -import com.readrops.app.util.theme.LargeSpacer -import com.readrops.app.util.theme.ShortSpacer -import com.readrops.app.util.theme.spacing -import com.readrops.db.filters.ListSortType -import com.readrops.db.queries.QueryFilters - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FilterBottomSheet( - onSetShowReadItemsState: () -> Unit, - onSetSortTypeState: () -> Unit, - filters: QueryFilters, - onDismiss: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismiss - ) { - Column( - modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) - ) { - Text( - text = stringResource(R.string.filters) - ) - - ShortSpacer() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onSetShowReadItemsState) - ) { - Checkbox( - checked = filters.showReadItems, - onCheckedChange = { onSetShowReadItemsState() } - ) - - ShortSpacer() - - Text( - text = stringResource(R.string.show_read_articles) - ) - } - - ShortSpacer() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onSetSortTypeState) - ) { - Checkbox( - checked = filters.sortType == ListSortType.OLDEST_TO_NEWEST, - onCheckedChange = { onSetSortTypeState() } - ) - - ShortSpacer() - - Text( - text = stringResource(R.string.show_oldest_articles_first) - ) - } - - LargeSpacer() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt b/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt deleted file mode 100644 index e26c9ef4b..000000000 --- a/app/src/main/java/com/readrops/app/timelime/TimelineItem.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.readrops.app.timelime - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.readrops.app.util.DefaultPreview -import com.readrops.app.util.theme.ReadropsTheme -import com.readrops.db.entities.Folder -import com.readrops.db.pojo.ItemWithFeed -import java.time.LocalDateTime - -enum class TimelineItemSize { - COMPACT, - REGULAR, - LARGE -} - -@Composable -fun TimelineItem( - itemWithFeed: ItemWithFeed, - onClick: () -> Unit, - onFavorite: () -> Unit, - onShare: () -> Unit, - modifier: Modifier = Modifier, - size: TimelineItemSize = TimelineItemSize.LARGE, -) { - when (size) { - TimelineItemSize.COMPACT -> { - CompactTimelineItem( - itemWithFeed = itemWithFeed, - onClick = onClick, - onFavorite = onFavorite, - onShare = onShare, - modifier = modifier - ) - } - TimelineItemSize.REGULAR -> { - RegularTimelineItem( - itemWithFeed = itemWithFeed, - onClick = onClick, - onFavorite = onFavorite, - onShare = onShare, - modifier = modifier - ) - } - TimelineItemSize.LARGE -> { - LargeTimelineItem( - itemWithFeed = itemWithFeed, - onClick = onClick, - onFavorite = onFavorite, - onShare = onShare, - modifier = modifier - ) - } - } -} - -private val itemWithFeed = ItemWithFeed( - item = com.readrops.db.entities.Item( - title = "This is a not so long item title", - pubDate = LocalDateTime.now(), - cleanDescription = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Donec a tortor neque. Nam ultrices, diam ac congue finibus, tortor sem congue urna, - at finibus elit libero at mi. Etiam hendrerit sapien eu porta feugiat. Duis porttitor""" - .replace("\n", "") - .trimMargin(), - imageLink = "" - ), - feedName = "feed name", - color = 0, - feedId = 0, - feedIconUrl = "", - websiteUrl = "", - folder = Folder(name = "Folder name") -) - -@DefaultPreview -@Composable -private fun RegularTimelineItemPreview() { - ReadropsTheme { - RegularTimelineItem( - itemWithFeed = itemWithFeed, - onClick = {}, - onFavorite = {}, - onShare = {}, - ) - } -} - -@DefaultPreview -@Composable -private fun CompactTimelineItemPreview() { - ReadropsTheme { - CompactTimelineItem( - itemWithFeed = itemWithFeed, - onClick = {}, - onFavorite = {}, - onShare = {}, - ) - } -} - -@DefaultPreview -@Composable -private fun LargeTimelineItemPreview() { - ReadropsTheme { - LargeTimelineItem( - itemWithFeed = itemWithFeed, - onClick = {}, - onFavorite = {}, - onShare = {}, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt b/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt index 8f8e344b6..84b996c93 100644 --- a/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt +++ b/app/src/main/java/com/readrops/app/timelime/TimelineScreenModel.kt @@ -1,7 +1,6 @@ package com.readrops.app.timelime import android.content.Context -import android.content.Intent import androidx.compose.runtime.Stable import androidx.paging.Pager import androidx.paging.PagingConfig @@ -9,33 +8,48 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.work.workDataOf import cafe.adriel.voyager.core.model.screenModelScope -import com.readrops.app.base.TabScreenModel +import com.readrops.app.R +import com.readrops.app.home.TabScreenModel import com.readrops.app.repositories.ErrorResult import com.readrops.app.repositories.GetFoldersWithFeeds import com.readrops.app.sync.SyncWorker +import com.readrops.app.timelime.components.SwipeAction +import com.readrops.app.timelime.components.TimelineItemSize +import com.readrops.app.util.PAGING_INITIAL_SIZE +import com.readrops.app.util.PAGING_PAGE_SIZE +import com.readrops.app.util.PAGING_PREFETCH_DISTANCE import com.readrops.app.util.Preferences -import com.readrops.app.util.clearSerializables -import com.readrops.app.util.getSerializable +import com.readrops.app.util.Utils +import com.readrops.app.util.extensions.clearSerializables +import com.readrops.app.util.extensions.getSerializable +import com.readrops.app.util.extensions.isConnected import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder import com.readrops.db.entities.Item -import com.readrops.db.filters.ListSortType +import com.readrops.db.entities.OpenIn import com.readrops.db.filters.MainFilter +import com.readrops.db.filters.OrderField +import com.readrops.db.filters.OrderType +import com.readrops.db.filters.QueryFilters import com.readrops.db.filters.SubFilter import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.queries.ItemSelectionQueryBuilder import com.readrops.db.queries.ItemsQueryBuilder -import com.readrops.db.queries.QueryFilters import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -44,8 +58,9 @@ class TimelineScreenModel( private val database: Database, private val getFoldersWithFeeds: GetFoldersWithFeeds, private val preferences: Preferences, + private val context: Context, private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : TabScreenModel(database) { +) : TabScreenModel(database, context) { private val _timelineState = MutableStateFlow(TimelineState()) val timelineState = _timelineState.asStateFlow() @@ -57,16 +72,42 @@ class TimelineScreenModel( private val filters = MutableStateFlow(_timelineState.value.filters) + private val useCustomShareIntentTpl = preferences.useCustomShareIntentTpl.flow.stateIn( + screenModelScope, SharingStarted.Eagerly, false + ) + private val customShareIntentTpl = preferences.customShareIntentTpl.flow.stateIn( + screenModelScope, SharingStarted.Eagerly, "" + ) + init { screenModelScope.launch(dispatcher) { + var syncAtLaunch = preferences.synchAtLaunch.flow.first() + filters.update { it.copy(mainFilter = MainFilter.valueOf(preferences.mainFilter.flow.first())) } + combine( accountEvent, - filters - ) { account, filters -> - account to filters.copy(accountId = account.id) - }.collectLatest { (account, filters) -> - this@TimelineScreenModel.filters.update { filters } - buildPager() + filters, + getTimelinePreferences() + ) { account, filters, timelinePreferences -> + Triple(account, filters.copy(accountId = account.id), timelinePreferences) + }.collectLatest { (account, filters, timelinePreferences) -> + _timelineState.update { + it.copy( + preferences = timelinePreferences, + filters = filters.copy( + showReadItems = timelinePreferences.showReadItems, + orderField = timelinePreferences.orderField, + orderType = timelinePreferences.orderType + ) + ) + } + + if (syncAtLaunch) { + refreshTimeline() + syncAtLaunch = false + } else { + buildPager() + } preferences.hideReadFeeds.flow .flatMapLatest { hideReadFeeds -> @@ -84,7 +125,6 @@ class TimelineScreenModel( ) } } - } } @@ -97,40 +137,56 @@ class TimelineScreenModel( } } } + } - screenModelScope.launch(dispatcher) { - combine( - preferences.timelineItemSize.flow, - preferences.scrollRead.flow, - preferences.displayNotificationsPermission.flow - ) { a, b, c -> Triple(a, b, c) } - .collect { (itemSize, scrollRead, notificationPermission) -> - _timelineState.update { - it.copy( - itemSize = when (itemSize) { - "compact" -> TimelineItemSize.COMPACT - "regular" -> TimelineItemSize.REGULAR - else -> TimelineItemSize.LARGE - }, - markReadOnScroll = scrollRead, - displayNotificationsPermission = notificationPermission - ) - } - } - } + private fun getTimelinePreferences(): Flow = with(preferences) { + return combine( + timelineItemSize.flow, + scrollRead.flow, + displayNotificationsPermission.flow, + showReadItems.flow, + orderField.flow, + orderType.flow, + theme.flow, + openLinksWith.flow, + globalOpenInAsk.flow, + synchAtLaunch.flow, + swipeToLeft.flow, + swipeToRight.flow, + transform = { + TimelinePreferences( + itemSize = when (it[0]) { + "compact" -> TimelineItemSize.COMPACT + "regular" -> TimelineItemSize.REGULAR + else -> TimelineItemSize.LARGE + }, + markReadOnScroll = it[1] as Boolean, + displayNotificationsPermission = it[2] as Boolean, + showReadItems = it[3] as Boolean, + orderField = OrderField.valueOf(it[4] as String), + orderType = OrderType.valueOf(it[5] as String), + theme = it[6] as String, + openInExternalBrowser = it[7] as String == "external_navigator", + openInAsk = it[8] as Boolean, + syncAtLaunch = it[9] as Boolean, + swipeToLeft = SwipeAction.valueOf(it[10] as String), + swipeToRight = SwipeAction.valueOf(it[11] as String) + ) + } + ) } private fun buildPager(empty: Boolean = false) { val query = ItemsQueryBuilder.buildItemsQuery( - filters.value, - currentAccount!!.config.useSeparateState + queryFilters = _timelineState.value.filters, + separateState = currentAccount!!.config.useSeparateState ) val pager = Pager( config = PagingConfig( - initialLoadSize = 50, - pageSize = 50, - prefetchDistance = 15 + initialLoadSize = PAGING_INITIAL_SIZE, + pageSize = PAGING_PAGE_SIZE, + prefetchDistance = PAGING_PREFETCH_DISTANCE ), pagingSourceFactory = { database.itemDao().selectAll(query) @@ -155,14 +211,19 @@ class TimelineScreenModel( } @Suppress("UNCHECKED_CAST") - fun refreshTimeline(context: Context) { + fun refreshTimeline() { + if (!context.isConnected()) { + _timelineState.update { it.copy(syncError = context.getString(R.string.no_network)) } + return + } + buildPager(empty = true) screenModelScope.launch(dispatcher) { - val filterPair = with(filters.value) { + val filterPair = with(_timelineState.value.filters) { when (subFilter) { - SubFilter.FEED -> SyncWorker.FEED_ID_KEY to filterFeedId - SubFilter.FOLDER -> SyncWorker.FOLDER_ID_KEY to filterFolderId + SubFilter.FEED -> SyncWorker.FEED_ID_KEY to feedId + SubFilter.FOLDER -> SyncWorker.FOLDER_ID_KEY to folderId else -> null } } @@ -201,6 +262,7 @@ class TimelineScreenModel( buildPager() } + workInfo.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false) -> { val error = workInfo.outputData.getSerializable(SyncWorker.SYNC_FAILURE_EXCEPTION_KEY) as Exception? @@ -208,7 +270,7 @@ class TimelineScreenModel( _timelineState.update { it.copy( - syncError = error, + syncError = accountError?.genericMessage(error!!), isRefreshing = false, hideReadAllFAB = false ) @@ -216,6 +278,7 @@ class TimelineScreenModel( buildPager() } + workInfo.progress.getString(SyncWorker.FEED_NAME_KEY) != null -> { _timelineState.update { it.copy( @@ -247,8 +310,8 @@ class TimelineScreenModel( it.filters.copy( mainFilter = selection, subFilter = SubFilter.ALL, - filterFeedId = 0, - filterFolderId = 0 + feedId = 0, + folderId = 0 ) }, isDrawerOpen = false @@ -262,8 +325,8 @@ class TimelineScreenModel( filters = updateFilters { it.filters.copy( subFilter = SubFilter.FOLDER, - filterFolderId = folder.id, - filterFeedId = 0 + folderId = folder.id, + feedId = 0 ) }, filterFolderName = folder.name!!, @@ -278,8 +341,8 @@ class TimelineScreenModel( filters = updateFilters { it.filters.copy( subFilter = SubFilter.FEED, - filterFeedId = feed.id, - filterFolderId = 0 + feedId = feed.id, + folderId = 0 ) }, filterFeedName = feed.name!!, @@ -297,15 +360,21 @@ class TimelineScreenModel( fun setItemRead(item: Item) { item.isRead = true - updateItemReadState(item) - } - private fun updateItemReadState(item: Item) { screenModelScope.launch(dispatcher) { repository?.setItemReadState(item) } } + fun updateItemReadState(item: Item) { + screenModelScope.launch(dispatcher) { + with(item) { + isRead = !isRead + repository?.setItemReadState(this) + } + } + } + fun updateStarState(item: Item) { screenModelScope.launch(dispatcher) { with(item) { @@ -315,26 +384,17 @@ class TimelineScreenModel( } } - fun shareItem(item: Item, context: Context) { - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, item.link) - }.also { - context.startActivity(Intent.createChooser(it, null)) - } - } fun setAllItemsRead() { screenModelScope.launch(dispatcher) { when (_timelineState.value.filters.subFilter) { SubFilter.FEED -> repository?.setAllItemsReadByFeed( - feedId = _timelineState.value.filters.filterFeedId + feedId = _timelineState.value.filters.feedId ) SubFilter.FOLDER -> repository?.setAllItemsReadByFolder( - folderId = _timelineState.value.filters.filterFolderId + folderId = _timelineState.value.filters.folderId ) else -> when (_timelineState.value.filters.mainFilter) { @@ -357,26 +417,36 @@ class TimelineScreenModel( } fun setShowReadItemsState(showReadItems: Boolean) { - _timelineState.update { - it.copy( - filters = updateFilters { - it.filters.copy( - showReadItems = showReadItems - ) - } - ) + screenModelScope.launch { + preferences.showReadItems.write(showReadItems) + + _timelineState.update { + it.copy( + filters = it.filters.copy(showReadItems = showReadItems) + ) + } } } - fun setSortTypeState(sortType: ListSortType) { - _timelineState.update { - it.copy( - filters = updateFilters { - it.filters.copy( - sortType = sortType - ) - } - ) + fun setOrderFieldState(orderField: OrderField) { + screenModelScope.launch { + preferences.orderField.write(orderField.name) + + _timelineState.update { + it.copy( + filters = it.filters.copy(orderField = orderField) + ) + } + } + } + + fun setOrderTypeState(orderType: OrderType) { + screenModelScope.launch { + preferences.orderType.write(orderType.name) + + _timelineState.update { + it.copy(filters = it.filters.copy(orderType = orderType)) + } } } @@ -397,6 +467,24 @@ class TimelineScreenModel( preferences.displayNotificationsPermission.write(false) } } + + suspend fun selectItemWithFeed(itemId: Int): ItemWithFeed? { + val query = + ItemSelectionQueryBuilder.buildQuery(itemId, currentAccount!!.config.useSeparateState) + return database.itemDao().selectItemById(query).firstOrNull() + } + + fun updateOpenInParameter(feedId: Int, openIn: OpenIn, openInAsk: Boolean) { + screenModelScope.launch(dispatcher) { + database.feedDao().updateOpenInSetting(feedId, openIn) + database.feedDao().updateOpenInAsk(feedId, false) + preferences.globalOpenInAsk.write(openInAsk) + } + } + + fun shareItem(item: Item, context: Context) = Utils.shareItem( + item, context, useCustomShareIntentTpl.value, customShareIntentTpl.value + ) } @Stable @@ -409,7 +497,7 @@ data class TimelineState( val feedMax: Int = 0, val scrollToTop: Boolean = false, val localSyncErrors: ErrorResult? = null, - val syncError: Exception? = null, + val syncError: String? = null, val filters: QueryFilters = QueryFilters(), val filterFeedName: String = "", val filterFolderName: String = "", @@ -418,9 +506,7 @@ data class TimelineState( val dialog: DialogState? = null, val isAccountLocal: Boolean = false, val hideReadAllFAB: Boolean = false, - val itemSize: TimelineItemSize = TimelineItemSize.LARGE, - val markReadOnScroll: Boolean = false, - val displayNotificationsPermission: Boolean = false + val preferences: TimelinePreferences = TimelinePreferences() ) { val showSubtitle = filters.subFilter != SubFilter.ALL @@ -428,8 +514,25 @@ data class TimelineState( val displayRefreshScreen = isRefreshing && isAccountLocal } +@Stable +data class TimelinePreferences( + val itemSize: TimelineItemSize = TimelineItemSize.LARGE, + val markReadOnScroll: Boolean = false, + val displayNotificationsPermission: Boolean = false, + val showReadItems: Boolean = true, + val orderField: OrderField = OrderField.DATE, + val orderType: OrderType = OrderType.DESC, + val theme: String = "light", + val openInExternalBrowser: Boolean = false, + val openInAsk: Boolean = true, + val syncAtLaunch: Boolean = false, + val swipeToLeft: SwipeAction = SwipeAction.READ, + val swipeToRight: SwipeAction = SwipeAction.DISABLED +) + sealed interface DialogState { data object ConfirmDialog : DialogState data object FilterSheet : DialogState class ErrorList(val errorResult: ErrorResult) : DialogState + class OpenIn(val itemWithFeed: ItemWithFeed) : DialogState } diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt b/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt index ce706d6b8..5355a37e6 100644 --- a/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt +++ b/app/src/main/java/com/readrops/app/timelime/TimelineTab.kt @@ -7,32 +7,24 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshContainer -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -40,42 +32,51 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import com.readrops.app.MainActivity import com.readrops.app.R import com.readrops.app.item.ItemScreen +import com.readrops.app.timelime.components.TimelineAppBar +import com.readrops.app.timelime.components.TimelineItem +import com.readrops.app.timelime.components.TimelineItemSize +import com.readrops.app.timelime.dialog.TimelineDialogs import com.readrops.app.timelime.drawer.TimelineDrawer -import com.readrops.app.util.ErrorMessage -import com.readrops.app.util.components.CenteredProgressIndicator +import com.readrops.app.util.components.LoadingScreen import com.readrops.app.util.components.Placeholder import com.readrops.app.util.components.RefreshScreen -import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.app.util.extensions.isError +import com.readrops.app.util.extensions.isLoading +import com.readrops.app.util.extensions.isNotEmpty +import com.readrops.app.util.extensions.openInCustomTab +import com.readrops.app.util.extensions.openUrl import com.readrops.app.util.theme.spacing -import com.readrops.db.filters.ListSortType +import com.readrops.db.entities.OpenIn import com.readrops.db.filters.MainFilter -import com.readrops.db.filters.SubFilter import com.readrops.db.pojo.ItemWithFeed +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow object TimelineTab : Tab { + private val openItemChannel = Channel() + override val options: TabOptions @Composable get() = TabOptions( @@ -90,41 +91,66 @@ object TimelineTab : Tab { val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current - val screenModel = getScreenModel() + val screenModel = koinScreenModel() val state by screenModel.timelineState.collectAsStateWithLifecycle() + val preferences = state.preferences val items = state.itemState.collectAsLazyPagingItems() val lazyListState = rememberLazyListState() - val pullToRefreshState = rememberPullToRefreshState() val snackbarHostState = remember { SnackbarHostState() } val topAppBarState = rememberTopAppBarState() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) - val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { - screenModel.disableDisplayNotificationsPermission() + val lazyColumnPadding = if (preferences.itemSize == TimelineItemSize.COMPACT) { + 0.dp + } else { + MaterialTheme.spacing.shortSpacing } - LaunchedEffect(state.displayNotificationsPermission) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU - && state.displayNotificationsPermission - ) { - launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + val launcher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + screenModel.disableDisplayNotificationsPermission() } - } - LaunchedEffect(state.isRefreshing) { - if (state.isRefreshing) { - pullToRefreshState.startRefresh() + fun openItem( + itemWithFeed: ItemWithFeed, + itemIndex: Int, + openIn: OpenIn? = itemWithFeed.openIn + ) { + val url = itemWithFeed.item.link!! + + if (openIn == OpenIn.LOCAL_VIEW) { + navigator.push(ItemScreen(itemWithFeed.item.id, itemIndex, state.filters)) } else { - pullToRefreshState.endRefresh() + screenModel.setItemRead(itemWithFeed.item) + + if (preferences.openInExternalBrowser) { + context.openUrl(url) + } else { + context.openInCustomTab(url, preferences.theme, Color(itemWithFeed.color)) + } } } - // Material3 pull to refresh doesn't have a onRefresh callback, - // so we need to listen to the internal state change to trigger the refresh - LaunchedEffect(pullToRefreshState.isRefreshing) { - if (pullToRefreshState.isRefreshing && !state.isRefreshing) { - screenModel.refreshTimeline(context) + LaunchedEffect(Unit) { + openItemChannel.receiveAsFlow() + .collect { itemId -> + screenModel.selectItemWithFeed(itemId) + ?.let { + openItem( + itemWithFeed = it, + itemIndex = items.itemSnapshotList + .indexOfFirst { itemWithFeed -> itemWithFeed?.item?.id == itemId }, + ) + } + } + } + + LaunchedEffect(preferences.displayNotificationsPermission) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU + && preferences.displayNotificationsPermission + ) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } } @@ -136,6 +162,19 @@ object TimelineTab : Tab { } } + // remove splash screen when opening the app + LaunchedEffect(items.isLoading(), preferences.syncAtLaunch) { + val activity = (context as MainActivity) + + if (preferences.syncAtLaunch) { + activity.ready = true + } else { + if (!items.isLoading() && !activity.ready) { + activity.ready = true + } + } + } + val drawerState = rememberDrawerState( initialValue = DrawerValue.Closed, confirmStateChange = { @@ -184,131 +223,38 @@ object TimelineTab : Tab { LaunchedEffect(state.syncError) { if (state.syncError != null) { - snackbarHostState.showSnackbar(ErrorMessage.get(state.syncError!!, context)) + snackbarHostState.showSnackbar(state.syncError!!) screenModel.resetSyncError() } } - when (val dialog = state.dialog) { - is DialogState.ConfirmDialog -> { - TwoChoicesDialog( - title = stringResource(R.string.mark_all_articles_read), - text = stringResource(R.string.mark_all_articles_read_question), - icon = painterResource(id = R.drawable.ic_rss_feed_grey), - confirmText = stringResource(id = R.string.validate), - dismissText = stringResource(id = R.string.cancel), - onDismiss = { screenModel.closeDialog() }, - onConfirm = { - screenModel.closeDialog() - screenModel.setAllItemsRead() - } + TimelineDialogs( + state = state, + screenModel = screenModel, + onOpenItem = { itemWithFeed, openIn -> + openItem( + itemWithFeed = itemWithFeed, + itemIndex = items.itemSnapshotList.indexOfFirst { it?.item?.id == itemWithFeed.item.id }, + openIn = openIn, ) } + ) - is DialogState.FilterSheet -> { - FilterBottomSheet( - filters = state.filters, - onSetShowReadItemsState = { - screenModel.setShowReadItemsState(!state.filters.showReadItems) - }, - onSetSortTypeState = { - screenModel.setSortTypeState( - if (state.filters.sortType == ListSortType.NEWEST_TO_OLDEST) - ListSortType.OLDEST_TO_NEWEST - else - ListSortType.NEWEST_TO_OLDEST - ) - }, - onDismiss = { screenModel.closeDialog() } - ) - } - - is DialogState.ErrorList -> { - ErrorListDialog( - errorResult = dialog.errorResult, - onDismiss = { screenModel.closeDialog(dialog) } - ) - } - - null -> {} - } - - ModalNavigationDrawer( + TimelineDrawer( + state = state, drawerState = drawerState, - drawerContent = { - TimelineDrawer( - state = state, - onClickDefaultItem = { - screenModel.updateDrawerDefaultItem(it) - }, - onFolderClick = { - screenModel.updateDrawerFolderSelection(it) - }, - onFeedClick = { - screenModel.updateDrawerFeedSelection(it) - } - ) - } + onClickDefaultItem = { screenModel.updateDrawerDefaultItem(it) }, + onFolderClick = { screenModel.updateDrawerFolderSelection(it) }, + onFeedClick = { screenModel.updateDrawerFeedSelection(it) } ) { Scaffold( topBar = { - TopAppBar( - title = { - Column { - Text( - text = when (state.filters.mainFilter) { - MainFilter.STARS -> stringResource(R.string.favorites) - MainFilter.ALL -> stringResource(R.string.articles) - MainFilter.NEW -> stringResource(R.string.new_articles) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (state.showSubtitle) { - Text( - text = when (state.filters.subFilter) { - SubFilter.FEED -> state.filterFeedName - SubFilter.FOLDER -> state.filterFolderName - else -> "" - }, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { screenModel.openDrawer() } - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = null - ) - } - }, - actions = { - IconButton( - onClick = { screenModel.openDialog(DialogState.FilterSheet) } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_filter_list), - contentDescription = null - ) - } - - IconButton( - onClick = { screenModel.refreshTimeline(context) } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_sync), - contentDescription = null - ) - } - }, - scrollBehavior = topAppBarScrollBehavior + TimelineAppBar( + state = state, + topAppBarScrollBehavior = topAppBarScrollBehavior, + onOpenDrawer = { screenModel.openDrawer() }, + onOpenFilterSheet = { screenModel.openDialog(DialogState.FilterSheet) }, + onRefreshTimeline = { screenModel.refreshTimeline() } ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -335,7 +281,6 @@ object TimelineTab : Tab { modifier = Modifier .padding(paddingValues) .fillMaxSize() - .nestedScroll(pullToRefreshState.nestedScrollConnection) .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { when { @@ -346,7 +291,7 @@ object TimelineTab : Tab { ) items.isLoading() -> { - CenteredProgressIndicator() + LoadingScreen(isRefreshing = state.isRefreshing) } items.isError() -> { @@ -357,79 +302,78 @@ object TimelineTab : Tab { } else -> { - if (items.itemCount > 0) { - MarkItemsRead( - lazyListState = lazyListState, - items = items, - markReadOnScroll = state.markReadOnScroll, - screenModel = screenModel - ) - - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues( - vertical = if (state.itemSize == TimelineItemSize.COMPACT) { - 0.dp - } else { - MaterialTheme.spacing.shortSpacing - } - ), - verticalArrangement = Arrangement.spacedBy( - if (state.itemSize == TimelineItemSize.COMPACT) { - 0.dp - } else - MaterialTheme.spacing.shortSpacing + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = { screenModel.refreshTimeline() }, + ) { + if (items.isNotEmpty()) { + MarkItemsRead( + lazyListState = lazyListState, + items = items, + markReadOnScroll = preferences.markReadOnScroll, + screenModel = screenModel ) - ) { - items( - count = items.itemCount, - key = items.itemKey { it.item.id }, - ) { itemCount -> - val itemWithFeed = items[itemCount] - - if (itemWithFeed != null) { - TimelineItem( - itemWithFeed = itemWithFeed, - onClick = { - screenModel.setItemRead(itemWithFeed.item) - navigator.push(ItemScreen(itemWithFeed.item.id)) - }, - onFavorite = { - screenModel.updateStarState(itemWithFeed.item) - }, - onShare = { - screenModel.shareItem( - itemWithFeed.item, - context - ) - }, - size = state.itemSize - ) + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(vertical = lazyColumnPadding), + verticalArrangement = Arrangement.spacedBy(lazyColumnPadding) + ) { + items( + count = items.itemCount, + key = items.itemKey { it.item.id }, + ) { index -> + val itemWithFeed = items[index] + + if (itemWithFeed != null) { + TimelineItem( + itemWithFeed = itemWithFeed, + swipeToLeft = state.preferences.swipeToLeft, + swipeToRight = state.preferences.swipeToRight, + onClick = { + if (itemWithFeed.openInAsk && preferences.openInAsk) { + screenModel.openDialog( + DialogState.OpenIn( + itemWithFeed + ) + ) + } else { + openItem( + itemWithFeed = itemWithFeed, + itemIndex = index, + ) + } + }, + onFavorite = { + screenModel.updateStarState(itemWithFeed.item) + }, + onShare = { + screenModel.shareItem( + itemWithFeed.item, + context + ) + }, + onSetReadState = { + screenModel.updateItemReadState(itemWithFeed.item) + }, + size = preferences.itemSize, + modifier = Modifier.animateItem() + ) + } } } + } else { + // Empty lazyColumn to let the pull to refresh be usable + // when no items are displayed + LazyColumn( + modifier = Modifier.fillMaxSize() + ) {} + + Placeholder( + text = stringResource(R.string.no_article), + painter = painterResource(R.drawable.ic_timeline), + ) } - - PullToRefreshContainer( - state = pullToRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) - } else { - // Empty lazyColumn to let the pull to refresh be usable - // when the no item placeholder is displayed - LazyColumn( - modifier = Modifier.fillMaxSize() - ) {} - - PullToRefreshContainer( - state = pullToRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) - - Placeholder( - text = stringResource(R.string.no_article), - painter = painterResource(R.drawable.ic_timeline), - ) } } } @@ -479,13 +423,8 @@ object TimelineTab : Tab { } } } -} - -fun LazyPagingItems.isLoading(): Boolean { - return loadState.refresh is LoadState.Loading && itemCount == 0 -} - -fun LazyPagingItems.isError(): Boolean { - return loadState.append is LoadState.Error //|| loadState.refresh is LoadState.Error + suspend fun openItem(itemId: Int) { + openItemChannel.send(itemId) + } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/components/TimelineAppBar.kt b/app/src/main/java/com/readrops/app/timelime/components/TimelineAppBar.kt new file mode 100644 index 000000000..a01a321f0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/components/TimelineAppBar.kt @@ -0,0 +1,93 @@ +package com.readrops.app.timelime.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.R +import com.readrops.app.timelime.TimelineState +import com.readrops.app.util.extensions.isTabletUi +import com.readrops.db.filters.MainFilter +import com.readrops.db.filters.SubFilter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimelineAppBar( + state: TimelineState, + topAppBarScrollBehavior: TopAppBarScrollBehavior, + onOpenDrawer: () -> Unit, + onOpenFilterSheet: () -> Unit, + onRefreshTimeline: () -> Unit, +) { + TopAppBar( + title = { + Column { + Text( + text = when (state.filters.mainFilter) { + MainFilter.STARS -> stringResource(R.string.favorites) + MainFilter.ALL -> stringResource(R.string.articles) + MainFilter.NEW -> stringResource(R.string.new_articles) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (state.showSubtitle) { + Text( + text = when (state.filters.subFilter) { + SubFilter.FEED -> state.filterFeedName + SubFilter.FOLDER -> state.filterFolderName + else -> "" + }, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + navigationIcon = { + if (!isTabletUi()) { + IconButton( + onClick = onOpenDrawer + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + } + }, + actions = { + IconButton( + onClick = onOpenFilterSheet + ) { + Icon( + painter = painterResource(id = R.drawable.ic_filter_list), + contentDescription = null + ) + } + + IconButton( + onClick = onRefreshTimeline + ) { + Icon( + painter = painterResource(id = R.drawable.ic_sync), + contentDescription = null + ) + } + }, + scrollBehavior = topAppBarScrollBehavior + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/components/TimelineItem.kt b/app/src/main/java/com/readrops/app/timelime/components/TimelineItem.kt new file mode 100644 index 000000000..5c845d204 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/components/TimelineItem.kt @@ -0,0 +1,257 @@ +package com.readrops.app.timelime.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.res.painterResource +import androidx.compose.ui.unit.dp +import com.readrops.app.R +import com.readrops.app.util.DefaultPreview +import com.readrops.app.util.theme.ReadropsTheme +import com.readrops.app.util.theme.spacing +import com.readrops.db.entities.Folder +import com.readrops.db.entities.OpenIn +import com.readrops.db.pojo.ItemWithFeed +import java.time.LocalDateTime + +enum class TimelineItemSize { + COMPACT, + REGULAR, + LARGE +} + +enum class SwipeAction { + READ, + FAVORITE, + DISABLED +} + +const val readAlpha = 0.6f + + +@Composable +fun TimelineItem( + itemWithFeed: ItemWithFeed, + swipeToLeft: SwipeAction, + swipeToRight: SwipeAction, + onClick: () -> Unit, + onFavorite: () -> Unit, + onShare: () -> Unit, + onSetReadState: () -> Unit, + modifier: Modifier = Modifier, + size: TimelineItemSize = TimelineItemSize.LARGE +) { + + fun handleSwipeAction(swipeAction: SwipeAction) { + when (swipeAction) { + SwipeAction.READ -> onSetReadState() + SwipeAction.FAVORITE -> onFavorite() + else -> {} + } + } + + val swipeState = rememberSwipeToDismissBoxState() + + LaunchedEffect(swipeState.currentValue) { + if (swipeState.currentValue == SwipeToDismissBoxValue.EndToStart) { + handleSwipeAction(swipeToLeft) + swipeState.dismiss(SwipeToDismissBoxValue.Settled) + } else if (swipeState.currentValue == SwipeToDismissBoxValue.StartToEnd) { + handleSwipeAction(swipeToRight) + swipeState.dismiss(SwipeToDismissBoxValue.Settled) + } + } + + fun getSwipeIcon(swipeAction: SwipeAction): Int { + return if (swipeAction == SwipeAction.READ) { + if (itemWithFeed.isRead) { + R.drawable.ic_remove_done + } else { + R.drawable.ic_done_all + } + } else { + if (itemWithFeed.isStarred) { + R.drawable.ic_star_outline + } else { + R.drawable.ic_star + } + } + } + + SwipeToDismissBox( + state = swipeState, + enableDismissFromStartToEnd = swipeToRight != SwipeAction.DISABLED, + enableDismissFromEndToStart = swipeToLeft != SwipeAction.DISABLED, + modifier = modifier, + backgroundContent = { + val color by animateColorAsState( + targetValue = when (swipeState.targetValue) { + SwipeToDismissBoxValue.EndToStart, SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primary + else -> Color.Transparent + }, + label = "Swipe to dismiss background color" + ) + + val iconColor by animateColorAsState( + targetValue = when (swipeState.targetValue) { + SwipeToDismissBoxValue.EndToStart, SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.onPrimary + else -> Color.Transparent + }, + label = "Swipe to dismiss icon color" + ) + + val icon = when (swipeState.targetValue) { + SwipeToDismissBoxValue.EndToStart -> getSwipeIcon(swipeToLeft) + SwipeToDismissBoxValue.StartToEnd -> getSwipeIcon(swipeToRight) + else -> null + } + + Box( + modifier = Modifier.padding( + horizontal = if (size == TimelineItemSize.COMPACT) { + 0.dp + } else { + MaterialTheme.spacing.shortSpacing + } + ) + ) { + Box( + contentAlignment = if (swipeState.targetValue == SwipeToDismissBoxValue.EndToStart) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + modifier = Modifier + .fillMaxSize() + .then( + if (size == TimelineItemSize.COMPACT) { + Modifier + } else { + Modifier.clip(CardDefaults.shape) + } + ) + .background(color) + ) { + if (icon != null) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = iconColor, + modifier = Modifier + .minimumInteractiveComponentSize() + .padding(horizontal = MaterialTheme.spacing.mediumSpacing) + ) + } + } + } + } + ) { + when (size) { + TimelineItemSize.COMPACT -> { + CompactTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + + TimelineItemSize.REGULAR -> { + RegularTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + + TimelineItemSize.LARGE -> { + LargeTimelineItem( + itemWithFeed = itemWithFeed, + onClick = onClick, + onFavorite = onFavorite, + onShare = onShare, + modifier = modifier + ) + } + } + } +} + +val itemWithFeed = ItemWithFeed( + item = com.readrops.db.entities.Item( + title = "This is a not so long item title", + pubDate = LocalDateTime.now(), + cleanDescription = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Donec a tortor neque. Nam ultrices, diam ac congue finibus, tortor sem congue urna, + at finibus elit libero at mi. Etiam hendrerit sapien eu porta feugiat. Duis porttitor""" + .replace("\n", "") + .trimMargin(), + imageLink = "" + ), + feedName = "feed name", + color = 0, + feedId = 0, + feedIconUrl = "", + websiteUrl = "", + folder = Folder(name = "Folder name"), + openIn = OpenIn.LOCAL_VIEW, + openInAsk = true +) + +@DefaultPreview +@Composable +private fun RegularTimelineItemPreview() { + ReadropsTheme { + RegularTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} + +@DefaultPreview +@Composable +private fun CompactTimelineItemPreview() { + ReadropsTheme { + CompactTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} + +@DefaultPreview +@Composable +private fun LargeTimelineItemPreview() { + ReadropsTheme { + LargeTimelineItem( + itemWithFeed = itemWithFeed, + onClick = {}, + onFavorite = {}, + onShare = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt b/app/src/main/java/com/readrops/app/timelime/components/TimelineItemParts.kt similarity index 77% rename from app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt rename to app/src/main/java/com/readrops/app/timelime/components/TimelineItemParts.kt index 7bda69646..d059562d8 100644 --- a/app/src/main/java/com/readrops/app/timelime/TimelineItemParts.kt +++ b/app/src/main/java/com/readrops/app/timelime/components/TimelineItemParts.kt @@ -1,8 +1,8 @@ -package com.readrops.app.timelime +package com.readrops.app.timelime.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -10,10 +10,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,18 +23,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.readrops.app.R import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.extensions.canDisplayOnBackground +import com.readrops.app.util.extensions.displayColor import com.readrops.app.util.theme.ShortSpacer import com.readrops.app.util.theme.spacing import com.readrops.db.pojo.ItemWithFeed @@ -51,8 +56,10 @@ fun RegularTimelineItem( onShare: () -> Unit, modifier: Modifier = Modifier ) { + val displayColor = itemWithFeed.displayColor(CardDefaults.cardColors().containerColor.toArgb()) + TimelineItemContainer( - isRead = itemWithFeed.item.isRead, + isRead = itemWithFeed.isRead, onClick = onClick, modifier = modifier ) { @@ -62,11 +69,11 @@ fun RegularTimelineItem( TimelineItemHeader( feedName = itemWithFeed.feedName, feedIconUrl = itemWithFeed.feedIconUrl, - feedColor = itemWithFeed.color, + feedColor = displayColor, folderName = itemWithFeed.folder?.name, date = itemWithFeed.item.pubDate!!, duration = itemWithFeed.item.readTime, - isStarred = itemWithFeed.item.isStarred, + isStarred = itemWithFeed.isStarred, onFavorite = onFavorite, onShare = onShare ) @@ -80,7 +87,7 @@ fun RegularTimelineItem( TimelineItemBadge( date = itemWithFeed.item.pubDate!!, duration = itemWithFeed.item.readTime, - color = itemWithFeed.color + color = displayColor ) } } @@ -94,12 +101,20 @@ fun CompactTimelineItem( onShare: () -> Unit, modifier: Modifier = Modifier ) { + val containerColor = MaterialTheme.colorScheme.background + val displayColor = itemWithFeed.displayColor(CardDefaults.cardColors().containerColor.toArgb()) + Surface( color = MaterialTheme.colorScheme.surfaceVariant, + onClick = onClick, modifier = modifier .fillMaxWidth() - .alpha(if (itemWithFeed.item.isRead) 0.6f else 1f) - .clickable { onClick() } + .drawBehind { + // if some alpha is applied to the card, the swipe to dismiss background appears behind it + // so we draw a rect with the current screen background color behind the card but in front of the dismiss background + drawRect(containerColor) + } + .alpha(if (itemWithFeed.isRead) readAlpha else 1f) ) { Column( modifier = Modifier.padding( @@ -111,13 +126,13 @@ fun CompactTimelineItem( TimelineItemHeader( feedName = itemWithFeed.feedName, feedIconUrl = itemWithFeed.feedIconUrl, - feedColor = itemWithFeed.color, + feedColor = displayColor, folderName = itemWithFeed.folder?.name, onFavorite = onFavorite, onShare = onShare, date = itemWithFeed.item.pubDate!!, duration = itemWithFeed.item.readTime, - isStarred = itemWithFeed.item.isStarred, + isStarred = itemWithFeed.isStarred, displayActions = false ) @@ -142,16 +157,19 @@ fun LargeTimelineItem( onShare: () -> Unit, modifier: Modifier = Modifier ) { + val displayColor = itemWithFeed.displayColor(CardDefaults.cardColors().containerColor.toArgb()) + if (itemWithFeed.item.cleanDescription == null && !itemWithFeed.item.hasImage) { RegularTimelineItem( itemWithFeed = itemWithFeed, onClick = onClick, onFavorite = onFavorite, - onShare = onShare + onShare = onShare, + modifier = modifier ) } else { TimelineItemContainer( - isRead = itemWithFeed.item.isRead, + isRead = itemWithFeed.isRead, onClick = onClick, modifier = modifier ) { @@ -162,11 +180,11 @@ fun LargeTimelineItem( TimelineItemHeader( feedName = itemWithFeed.feedName, feedIconUrl = itemWithFeed.feedIconUrl, - feedColor = itemWithFeed.color, + feedColor = displayColor, folderName = itemWithFeed.folder?.name, date = itemWithFeed.item.pubDate!!, duration = itemWithFeed.item.readTime, - isStarred = itemWithFeed.item.isStarred, + isStarred = itemWithFeed.isStarred, onFavorite = onFavorite, onShare = onShare ) @@ -176,7 +194,7 @@ fun LargeTimelineItem( TimelineItemBadge( date = itemWithFeed.item.pubDate!!, duration = itemWithFeed.item.readTime, - color = itemWithFeed.color + color = displayColor ) ShortSpacer() @@ -222,14 +240,25 @@ fun TimelineItemContainer( isRead: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = MaterialTheme.spacing.shortSpacing), content: @Composable () -> Unit ) { + val containerColor = MaterialTheme.colorScheme.background + Card( + onClick = onClick, modifier = modifier - .padding(horizontal = MaterialTheme.spacing.shortSpacing) + .padding(padding) .fillMaxWidth() - .alpha(if (isRead) 0.6f else 1f) - .clickable { onClick() } + .drawBehind { + // if some alpha is applied to the card, the swipe to dismiss background appears behind it + // so we draw a rect with the current screen background color behind the card but in front of the dismiss background + drawRoundRect( + color = containerColor, + cornerRadius = CornerRadius(12.dp.toPx()) + ) + } + .alpha(if (isRead) readAlpha else 1f) ) { content() } @@ -239,7 +268,7 @@ fun TimelineItemContainer( fun TimelineItemHeader( feedName: String, feedIconUrl: String?, - feedColor: Int, + feedColor: Color, folderName: String?, date: LocalDateTime, duration: Double, @@ -270,11 +299,7 @@ fun TimelineItemHeader( style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (feedColor != 0) { - Color(feedColor) - } else { - MaterialTheme.colorScheme.primary - }, + color = feedColor, ) if (!folderName.isNullOrEmpty()) { @@ -299,7 +324,10 @@ fun TimelineItemHeader( onClick = onFavorite ) { Icon( - imageVector = if (isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + painter = painterResource( + if (isStarred) R.drawable.ic_star + else R.drawable.ic_star_outline + ), contentDescription = null, ) } @@ -350,13 +378,14 @@ fun TimelineItemTitle( fun TimelineItemBadge( date: LocalDateTime, duration: Double, - color: Int, + color: Color, ) { - val textColor = if (color != 0) Color.White else MaterialTheme.colorScheme.onPrimary - + val onAccentColor = + if (Color.White.toArgb().canDisplayOnBackground(color.toArgb(), threshold = 2.5f)) + Color.White else Color.Black Surface( - color = if (color != 0) Color(color) else MaterialTheme.colorScheme.primary, + color = color, shape = RoundedCornerShape(48.dp) ) { Row( @@ -369,14 +398,14 @@ fun TimelineItemBadge( Text( text = DateUtils.formattedDateByLocal(date), style = MaterialTheme.typography.labelSmall, - color = textColor + color = onAccentColor ) Text( - text = "·", + text = stringResource(id = R.string.interpoint), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing), - color = textColor + color = onAccentColor ) Text( @@ -386,7 +415,7 @@ fun TimelineItemBadge( stringResource(id = R.string.read_time_lower_than_1) }, style = MaterialTheme.typography.labelSmall, - color = textColor + color = onAccentColor ) } } diff --git a/app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt b/app/src/main/java/com/readrops/app/timelime/dialog/ErrorListDialog.kt similarity index 84% rename from app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt rename to app/src/main/java/com/readrops/app/timelime/dialog/ErrorListDialog.kt index 3a05aa56a..2601d4818 100644 --- a/app/src/main/java/com/readrops/app/timelime/ErrorListDialog.kt +++ b/app/src/main/java/com/readrops/app/timelime/dialog/ErrorListDialog.kt @@ -1,4 +1,4 @@ -package com.readrops.app.timelime +package com.readrops.app.timelime.dialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.heightIn @@ -14,7 +14,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.readrops.app.R import com.readrops.app.repositories.ErrorResult -import com.readrops.app.util.ErrorMessage +import com.readrops.app.util.accounterror.AccountError import com.readrops.app.util.components.dialog.BaseDialog import com.readrops.app.util.theme.MediumSpacer import com.readrops.app.util.theme.ShortSpacer @@ -25,6 +25,7 @@ fun ErrorListDialog( onDismiss: () -> Unit, ) { val scrollableState = rememberScrollState() + val accountError = AccountError.Companion.DefaultAccountError(LocalContext.current) BaseDialog( title = stringResource(R.string.synchronization_errors), @@ -45,7 +46,7 @@ fun ErrorListDialog( modifier = Modifier.verticalScroll(scrollableState) ) { for (error in errorResult.entries) { - Text(text = "${error.key.name}: ${ErrorMessage.get(error.value, LocalContext.current)}") + Text(text = "${error.key.name}: ${accountError.genericMessage(error.value)}") ShortSpacer() } diff --git a/app/src/main/java/com/readrops/app/timelime/dialog/FilterBottomSheet.kt b/app/src/main/java/com/readrops/app/timelime/dialog/FilterBottomSheet.kt new file mode 100644 index 000000000..9d598467d --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/dialog/FilterBottomSheet.kt @@ -0,0 +1,187 @@ +package com.readrops.app.timelime.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.util.DefaultPreview +import com.readrops.app.util.theme.LargeSpacer +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.ReadropsTheme +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.app.util.theme.spacing +import com.readrops.db.filters.OrderField +import com.readrops.db.filters.OrderType +import com.readrops.db.filters.QueryFilters +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterBottomSheet( + filters: QueryFilters, + onSetShowReadItems: () -> Unit, + onSetOrderField: () -> Unit, + onSetOrderType: () -> Unit, + onDismiss: () -> Unit +) { + val tooltipState = rememberTooltipState(isPersistent = true) + val coroutineScope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + //sheetState = rememberStandardBottomSheetState() + ) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + Text( + text = stringResource(R.string.filters), + style = MaterialTheme.typography.titleMedium + ) + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSetShowReadItems) + ) { + Checkbox( + checked = filters.showReadItems, + onCheckedChange = { onSetShowReadItems() } + ) + + ShortSpacer() + + Text( + text = stringResource(R.string.show_read_articles) + ) + } + + ShortSpacer() + + Column( + modifier = Modifier.width(IntrinsicSize.Max) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.order_by)) + + TooltipBox( + positionProvider = TooltipDefaults.rememberRichTooltipPositionProvider(), + tooltip = { + RichTooltip( + title = { Text(text = stringResource(id = R.string.order_by)) } + ) { + Text( + text = stringResource(R.string.order_field_tooltip), + ) + } + }, + state = tooltipState + ) { + IconButton( + onClick = { + coroutineScope.launch { + tooltipState.show() + } + } + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null + ) + } + } + } + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ) { + SegmentedButton( + selected = filters.orderField == OrderField.ID, + onClick = onSetOrderField, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) + ) { + Text(text = stringResource(R.string.identifier)) + } + + SegmentedButton( + selected = filters.orderField == OrderField.DATE, + onClick = onSetOrderField, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) + ) { + Text(text = stringResource(R.string.date)) + } + } + + MediumSpacer() + + Text(text = stringResource(R.string.with_direction)) + + ShortSpacer() + + SingleChoiceSegmentedButtonRow { + SegmentedButton( + selected = filters.orderType == OrderType.ASC, + onClick = onSetOrderType, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) + ) { + Text(text = stringResource(R.string.ascending)) + } + + SegmentedButton( + selected = filters.orderType == OrderType.DESC, + onClick = onSetOrderType, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) + ) { + Text(text = stringResource(R.string.descending)) + } + } + } + + LargeSpacer() + } + } +} + +@DefaultPreview +@Composable +private fun FilterBottomSheetPreview() { + ReadropsTheme { + FilterBottomSheet( + onSetShowReadItems = {}, + onSetOrderType = {}, + onSetOrderField = {}, + filters = QueryFilters(), + onDismiss = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/dialog/OpenInParameterDialog.kt b/app/src/main/java/com/readrops/app/timelime/dialog/OpenInParameterDialog.kt new file mode 100644 index 000000000..d1cecffd0 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/dialog/OpenInParameterDialog.kt @@ -0,0 +1,89 @@ +package com.readrops.app.timelime.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.more.preferences.components.RadioButtonItem +import com.readrops.app.util.components.dialog.BaseDialog +import com.readrops.app.util.theme.ShortSpacer +import com.readrops.db.entities.OpenIn + +@Composable +fun OpenInParameterDialog( + openIn: OpenIn, + onValidate: (openIn: OpenIn, openInAsk: Boolean) -> Unit, + onDismiss: () -> Unit +) { + var currentOpenIn by remember(openIn) { mutableStateOf(OpenIn.LOCAL_VIEW) } + var isChecked by remember { mutableStateOf(false) } + + BaseDialog( + title = stringResource(R.string.open_feed_in), + icon = painterResource(R.drawable.ic_open_in_browser), + onDismiss = onDismiss + ) { + RadioButtonItem( + text = stringResource(R.string.local_view), + isSelected = currentOpenIn == OpenIn.LOCAL_VIEW, + onClick = { currentOpenIn = OpenIn.LOCAL_VIEW } + ) + + RadioButtonItem( + text = stringResource(R.string.external_view), + isSelected = currentOpenIn == OpenIn.EXTERNAL_VIEW, + onClick = { currentOpenIn = OpenIn.EXTERNAL_VIEW } + ) + + ShortSpacer() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isChecked = !isChecked } + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { isChecked = it } + ) + + Text( + text = stringResource(R.string.do_not_ask_again_next_feeds), + style = MaterialTheme.typography.bodySmall + ) + } + + ShortSpacer() + + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = onDismiss + ) { + Text(text = stringResource(R.string.cancel)) + } + + TextButton( + onClick = { onValidate(currentOpenIn, !isChecked) } + ) { + Text(text = stringResource(R.string.validate)) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/dialog/TimelineDialogs.kt b/app/src/main/java/com/readrops/app/timelime/dialog/TimelineDialogs.kt new file mode 100644 index 000000000..299ae7295 --- /dev/null +++ b/app/src/main/java/com/readrops/app/timelime/dialog/TimelineDialogs.kt @@ -0,0 +1,95 @@ +package com.readrops.app.timelime.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.readrops.app.R +import com.readrops.app.timelime.DialogState +import com.readrops.app.timelime.TimelineScreenModel +import com.readrops.app.timelime.TimelineState +import com.readrops.app.util.components.dialog.TwoChoicesDialog +import com.readrops.db.entities.OpenIn +import com.readrops.db.filters.OrderField +import com.readrops.db.filters.OrderType +import com.readrops.db.pojo.ItemWithFeed + +@Composable +fun TimelineDialogs( + state: TimelineState, + screenModel: TimelineScreenModel, + onOpenItem: (ItemWithFeed, OpenIn) -> Unit +) { + when (val dialog = state.dialog) { + is DialogState.ConfirmDialog -> { + TwoChoicesDialog( + title = stringResource(R.string.mark_all_articles_read), + text = stringResource(R.string.mark_all_articles_read_question), + icon = painterResource(id = R.drawable.ic_rss_feed_grey), + confirmText = stringResource(id = R.string.validate), + dismissText = stringResource(id = R.string.cancel), + onDismiss = { screenModel.closeDialog() }, + onConfirm = { + screenModel.closeDialog() + screenModel.setAllItemsRead() + } + ) + } + + is DialogState.FilterSheet -> { + FilterBottomSheet( + filters = state.filters, + onSetShowReadItems = { + screenModel.setShowReadItemsState(!state.filters.showReadItems) + }, + onSetOrderField = { + screenModel.setOrderFieldState( + if (state.filters.orderField == OrderField.ID) { + OrderField.DATE + } else { + OrderField.ID + } + ) + }, + onSetOrderType = { + screenModel.setOrderTypeState( + if (state.filters.orderType == OrderType.DESC) { + OrderType.ASC + } else { + OrderType.DESC + } + ) + }, + onDismiss = { screenModel.closeDialog() } + ) + } + + is DialogState.ErrorList -> { + ErrorListDialog( + errorResult = dialog.errorResult, + onDismiss = { screenModel.closeDialog(dialog) } + ) + } + + is DialogState.OpenIn -> { + val itemWithFeed = dialog.itemWithFeed + + OpenInParameterDialog( + openIn = itemWithFeed.openIn!!, + onValidate = { openIn, openInAsk -> + screenModel.updateOpenInParameter( + feedId = itemWithFeed.feedId, + openIn = openIn, + openInAsk = openInAsk + ) + + screenModel.closeDialog(dialog) + + onOpenItem(itemWithFeed, openIn) + }, + onDismiss = { screenModel.closeDialog(dialog) } + ) + } + + else -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt index 6b1dc10cd..736d2993e 100644 --- a/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt +++ b/app/src/main/java/com/readrops/app/timelime/drawer/DrawerFolderItem.kt @@ -8,13 +8,18 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Surface @@ -77,34 +82,39 @@ fun DrawerFolderItem( ) ) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 16.dp, end = 24.dp) - ) { - val iconColor = colors.iconColor(selected).value - CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(0.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 24.dp).weight(1f), + ) { + val iconColor = colors.iconColor(selected).value + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) - DrawerSpacing() + DrawerSpacing() - Box(Modifier.weight(1f)) { - val labelColor = colors.textColor(selected).value - CompositionLocalProvider(LocalContentColor provides labelColor, content = label) - } - - DrawerSpacing() - - val badgeColor = colors.badgeColor(selected).value - CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + Box(Modifier.weight(1f)) { + val labelColor = colors.textColor(selected).value + CompositionLocalProvider( + LocalContentColor provides labelColor, + content = label + ) + } - DrawerSpacing() + DrawerSpacing() - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier - .clickable { isExpanded = isExpanded.not() } - .rotate(rotationState), - ) + val badgeColor = colors.badgeColor(selected).value + CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + } + IconButton( + onClick = { isExpanded = isExpanded.not() }, + modifier = Modifier.fillMaxHeight().aspectRatio(1f) + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(12.dp).rotate(rotationState), + ) + } } } @@ -113,7 +123,7 @@ fun DrawerFolderItem( DrawerFeedItem( label = { Text( - text = feed.name!!, + text = feed.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -121,7 +131,7 @@ fun DrawerFolderItem( icon = { FeedIcon( iconUrl = feed.iconUrl, - name = feed.name!! + name = feed.name.orEmpty() ) }, badge = { Text(feed.unreadCount.toString()) }, diff --git a/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt b/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt index 4c15eab76..41cb13748 100644 --- a/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt +++ b/app/src/main/java/com/readrops/app/timelime/drawer/TimelineDrawer.kt @@ -9,12 +9,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.Divider +import androidx.compose.material3.DrawerState +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,13 +29,52 @@ import androidx.compose.ui.unit.dp import com.readrops.app.R import com.readrops.app.timelime.TimelineState import com.readrops.app.util.components.FeedIcon +import com.readrops.app.util.extensions.isTabletUi import com.readrops.app.util.theme.spacing import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder import com.readrops.db.filters.MainFilter + @Composable fun TimelineDrawer( + state: TimelineState, + drawerState: DrawerState, + onClickDefaultItem: (MainFilter) -> Unit, + onFolderClick: (Folder) -> Unit, + onFeedClick: (Feed) -> Unit, + content: @Composable () -> Unit, +) { + if (isTabletUi()) { + PermanentNavigationDrawer( + drawerContent = { + TimelineDrawerContent( + state = state, + onClickDefaultItem = onClickDefaultItem, + onFolderClick = onFolderClick, + onFeedClick = onFeedClick + ) + }, + content = content + ) + } else { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + TimelineDrawerContent( + state = state, + onClickDefaultItem = onClickDefaultItem, + onFolderClick = onFolderClick, + onFeedClick = onFeedClick + ) + }, + content = content + ) + } +} + +@Composable +fun TimelineDrawerContent( state: TimelineState, onClickDefaultItem: (MainFilter) -> Unit, onFolderClick: (Folder) -> Unit, @@ -63,7 +105,7 @@ fun TimelineDrawer( DrawerFolderItem( label = { Text( - text = folder.name!!, + text = folder.name.orEmpty(), fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -78,10 +120,10 @@ fun TimelineDrawer( badge = { Text(folderEntry.value.sumOf { it.unreadCount }.toString()) }, - selected = state.filters.filterFolderId == folder.id, + selected = state.filters.folderId == folder.id, onClick = { onFolderClick(folder) }, feeds = folderEntry.value, - selectedFeed = state.filters.filterFeedId, + selectedFeed = state.filters.feedId, onFeedClick = { onFeedClick(it) }, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) @@ -92,7 +134,7 @@ fun TimelineDrawer( DrawerFeedItem( label = { Text( - text = feed.name!!, + text = feed.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -100,11 +142,11 @@ fun TimelineDrawer( icon = { FeedIcon( iconUrl = feed.iconUrl, - name = feed.name!! + name = feed.name.orEmpty() ) }, badge = { Text(feed.unreadCount.toString()) }, - selected = feed.id == state.filters.filterFeedId, + selected = feed.id == state.filters.feedId, onClick = { onFeedClick(feed) }, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) @@ -172,7 +214,7 @@ fun DrawerDefaultItems( @Composable fun DrawerDivider() { - Divider( + HorizontalDivider( thickness = 2.dp, modifier = Modifier.padding( vertical = MaterialTheme.spacing.drawerSpacing, diff --git a/app/src/main/java/com/readrops/app/util/CrashActivity.kt b/app/src/main/java/com/readrops/app/util/CrashActivity.kt index 6493fbd51..a2766ff2a 100644 --- a/app/src/main/java/com/readrops/app/util/CrashActivity.kt +++ b/app/src/main/java/com/readrops/app/util/CrashActivity.kt @@ -60,7 +60,11 @@ class CrashActivity : ComponentActivity() { val sw = StringWriter() val pw = PrintWriter(sw) try { - throwable?.printStackTrace(pw) + if (throwable?.cause != null) { + throwable.cause?.printStackTrace(pw) + } else { + throwable?.printStackTrace(pw) + } } catch (e: Exception) { Log.e("CrashActivity", "couldn't get full exception stacktrace") } diff --git a/app/src/main/java/com/readrops/app/util/ErrorMessage.kt b/app/src/main/java/com/readrops/app/util/ErrorMessage.kt deleted file mode 100644 index d48c5733e..000000000 --- a/app/src/main/java/com/readrops/app/util/ErrorMessage.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.readrops.app.util - -import android.content.Context -import com.readrops.api.utils.exceptions.HttpException -import com.readrops.api.utils.exceptions.LoginFailedException -import com.readrops.api.utils.exceptions.ParseException -import com.readrops.api.utils.exceptions.UnknownFormatException -import com.readrops.app.R -import java.io.IOException -import java.net.UnknownHostException - -object ErrorMessage { - - fun get(exception: Exception, context: Context) = when (exception) { - is HttpException -> getHttpMessage(exception, context) - is UnknownHostException -> context.resources.getString(R.string.unreachable_url) - is NoSuchFileException -> context.resources.getString(R.string.unable_open_file) - is IOException -> context.resources.getString(R.string.network_failure, exception.message.orEmpty()) - is ParseException, is UnknownFormatException -> context.resources.getString(R.string.processing_feed_error) - is LoginFailedException -> context.getString(R.string.login_failed) - else -> "${exception.javaClass.simpleName}: ${exception.message}" - } - - private fun getHttpMessage(exception: HttpException, context: Context): String { - return when (exception.code) { - in 400..499 -> { - when (exception.code) { - 400 -> context.resources.getString(R.string.http_error_400) - 401 -> context.resources.getString(R.string.http_error_401) - 403 -> context.resources.getString(R.string.http_error_403) - 404 -> context.resources.getString(R.string.http_error_404) - else -> context.resources.getString(R.string.http_error_4XX, exception.code) - } - } - - in 500..599 -> { - context.resources.getString(R.string.http_error_5XX, exception.code) - } - else -> context.resources.getString(R.string.http_error, exception.code) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/FeedColors.kt b/app/src/main/java/com/readrops/app/util/FeedColors.kt index c47a89678..9aed05438 100644 --- a/app/src/main/java/com/readrops/app/util/FeedColors.kt +++ b/app/src/main/java/com/readrops/app/util/FeedColors.kt @@ -2,8 +2,9 @@ package com.readrops.app.util import android.graphics.Bitmap import android.graphics.BitmapFactory -import androidx.annotation.ColorInt import androidx.palette.graphics.Palette +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.koin.core.component.KoinComponent @@ -13,6 +14,7 @@ object FeedColors : KoinComponent { suspend fun getFeedColor(feedUrl: String): Int { // use OkHttp directly instead of Coil as Coil doesn't respect OkHttp timeout + // TODO retry with Coil3? val response = get().newCall( Request.Builder() .url(feedUrl) @@ -20,35 +22,13 @@ object FeedColors : KoinComponent { ).execute() val bitmap = BitmapFactory.decodeStream(response.body?.byteStream()) ?: return 0 + return getFeedColor(bitmap) } - fun getFeedColor(bitmap: Bitmap): Int { + suspend fun getFeedColor(bitmap: Bitmap): Int = withContext(Dispatchers.Default) { val palette = Palette.from(bitmap).generate() - val dominantSwatch = palette.dominantSwatch - return if (dominantSwatch != null && !isColorTooBright(dominantSwatch.rgb) - && !isColorTooDark(dominantSwatch.rgb) - ) { - dominantSwatch.rgb - } else 0 - } - - private fun isColorTooBright(@ColorInt color: Int): Boolean { - return getColorLuma(color) > 210 - } - - private fun isColorTooDark(@ColorInt color: Int): Boolean { - return getColorLuma(color) < 40 + palette.dominantSwatch?.rgb ?: 0 } - - private fun getColorLuma(@ColorInt color: Int): Double { - val r = color shr 16 and 0xff - val g = color shr 8 and 0xff - val b = color shr 0 and 0xff - return 0.2126 * r + 0.7152 * g + 0.0722 * b - } - - fun isColorDark(color: Int) = getColorLuma(color) < 130 - } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt b/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt index d2034c553..db50bfe44 100644 --- a/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt +++ b/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt @@ -1,24 +1,23 @@ package com.readrops.app.util import android.util.Patterns -import coil.ImageLoader -import coil.annotation.ExperimentalCoilApi -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.disk.DiskCache -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.Options +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.disk.DiskCache +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options import okhttp3.OkHttpClient import okhttp3.Request +import okio.FileSystem data class FeedKey(val faviconUrl: String?) /** * Custom Coil Fetcher to load Feed favicons from either an http source or a file source */ -@OptIn(ExperimentalCoilApi::class) class FeverFaviconFetcher( private val data: FeedKey, private val diskCache: DiskCache, @@ -38,7 +37,7 @@ class FeverFaviconFetcher( val snapshot = diskCache.openSnapshot(diskCacheKey) return if (snapshot != null) { - SourceResult( + SourceFetchResult( source = snapshot.toImageSource(), mimeType = MIME_TYPE, dataSource = DataSource.DISK @@ -53,7 +52,7 @@ class FeverFaviconFetcher( val snapshot = diskCache.openSnapshot(diskCacheKey) return if (snapshot != null) { - SourceResult( + SourceFetchResult( source = snapshot.toImageSource(), mimeType = MIME_TYPE, dataSource = DataSource.NETWORK @@ -77,7 +76,7 @@ class FeverFaviconFetcher( } return if (httpSnapshot != null) { - SourceResult( + SourceFetchResult( source = httpSnapshot.toImageSource(), mimeType = MIME_TYPE, dataSource = DataSource.NETWORK @@ -91,8 +90,9 @@ class FeverFaviconFetcher( private fun DiskCache.Snapshot.toImageSource(): ImageSource { return ImageSource( file = data, + fileSystem = FileSystem.SYSTEM, diskCacheKey = this@FeverFaviconFetcher.data.faviconUrl, - closeable = this, + closeable = this ) } diff --git a/app/src/main/java/com/readrops/app/util/Migrations.kt b/app/src/main/java/com/readrops/app/util/Migrations.kt new file mode 100644 index 000000000..19d1cd9a6 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/Migrations.kt @@ -0,0 +1,48 @@ +package com.readrops.app.util + +import android.content.SharedPreferences +import com.readrops.app.BuildConfig +import com.readrops.db.Database +import kotlinx.coroutines.flow.first + +object Migrations { + + suspend fun upgrade( + appPreferences: Preferences, + encryptedPreferences: SharedPreferences, + oldPreferences: SharedPreferences, + database: Database + ) { + val lastVersionCode = appPreferences.lastVersionCode.flow + .first() + + // 2.0-beta02 + if (lastVersionCode < 16) { + val accounts = database.accountDao().selectAllAccounts().first() + + for (account in accounts) { + oldPreferences.getString(account.loginKey, null)?.run { + encryptedPreferences.edit() + .putString(account.loginKey, this) + .apply() + + oldPreferences.edit() + .remove(account.loginKey) + .apply() + } + + oldPreferences.getString(account.passwordKey, null)?.run { + encryptedPreferences.edit() + .putString(account.passwordKey, this) + .apply() + + oldPreferences.edit() + .remove(account.passwordKey) + .apply() + } + } + } + + appPreferences.lastVersionCode.write(BuildConfig.VERSION_CODE) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/PagingConstants.kt b/app/src/main/java/com/readrops/app/util/PagingConstants.kt new file mode 100644 index 000000000..ae29b21ce --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/PagingConstants.kt @@ -0,0 +1,7 @@ +package com.readrops.app.util + +const val PAGING_INITIAL_SIZE = 50 + +const val PAGING_PAGE_SIZE = 50 + +const val PAGING_PREFETCH_DISTANCE = 15 \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Preferences.kt b/app/src/main/java/com/readrops/app/util/Preferences.kt index 1d432955d..eed720e8a 100644 --- a/app/src/main/java/com/readrops/app/util/Preferences.kt +++ b/app/src/main/java/com/readrops/app/util/Preferences.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -31,7 +32,7 @@ class Preferences( default = "system" ) - val themeColourScheme = Preference( + val themeColorScheme = Preference( dataStore = dataStore, key = stringPreferencesKey("theme_color_scheme"), default = "readrops" @@ -72,6 +73,72 @@ class Preferences( key = booleanPreferencesKey("display_notification_permission"), default = true ) + + val lastVersionCode = Preference( + dataStore = dataStore, + key = intPreferencesKey("last_version_code"), + default = 0 + ) + + val showReadItems = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("show_read_items"), + default = true + ) + + val orderField = Preference( + dataStore = dataStore, + key = stringPreferencesKey("order_field"), + default = "DATE" // or "ID", uppercase important, used with Enum.valueOf() + ) + + val orderType = Preference( + dataStore = dataStore, + key = stringPreferencesKey("order_type"), + default = "DESC" // or "ASC", uppercase important, used with Enum.valueOf() + ) + + val globalOpenInAsk = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("open_in_ask"), + default = true + ) + + val mainFilter = Preference( + dataStore = dataStore, + key = stringPreferencesKey("main_filter"), + default = "ALL" // uppercase important, used with Enum.valueOf() + ) + + val synchAtLaunch = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("sync_at_launch"), + default = false + ) + + val useCustomShareIntentTpl = Preference( + dataStore = dataStore, + key = booleanPreferencesKey("use_custom_share_intent_tpl"), + default = false + ) + + val customShareIntentTpl = Preference( + dataStore = dataStore, + key = stringPreferencesKey("custom_share_intent_tpl"), + default = "" + ) + + val swipeToRight = Preference( + dataStore = dataStore, + key = stringPreferencesKey("swipe_to_right"), + default = "DISABLED" + ) + + val swipeToLeft = Preference( + dataStore = dataStore, + key = stringPreferencesKey("swipe_to_left_action"), + default = "READ" + ) } diff --git a/app/src/main/java/com/readrops/app/util/ShareIntentTextRenderer.kt b/app/src/main/java/com/readrops/app/util/ShareIntentTextRenderer.kt new file mode 100644 index 000000000..5a3d95c31 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/ShareIntentTextRenderer.kt @@ -0,0 +1,131 @@ +package com.readrops.app.util + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.util.fastJoinToString +import com.readrops.db.entities.Item +import io.pebbletemplates.pebble.PebbleEngine +import io.pebbletemplates.pebble.extension.AbstractExtension +import io.pebbletemplates.pebble.extension.Filter +import io.pebbletemplates.pebble.loader.StringLoader +import io.pebbletemplates.pebble.template.EvaluationContext +import io.pebbletemplates.pebble.template.PebbleTemplate +import org.koin.core.component.KoinComponent +import java.io.StringWriter +import com.readrops.app.R +import org.koin.core.component.get + + +abstract class DocumentedFilter : Filter, KoinComponent { + val documentation by lazy { generateDocumentation(get()) } + protected abstract fun generateDocumentation(context: Context): String +} + +@VisibleForTesting +class RemoveAuthorFilter : DocumentedFilter() { + override fun apply( + input: Any?, + args: Map?, + self: PebbleTemplate?, + context: EvaluationContext?, + lineNumber: Int + ): Any? { + val author = context?.getVariable("author")?.toString() + if (input == null || author.isNullOrBlank()) return input + return filter(input.toString(), author) + } + + override fun getArgumentNames(): List? = null + + override fun generateDocumentation(context: Context) = + context.getString(R.string.remove_author_documentation) + + companion object { + /** + * Regex that matches any character that someone may use as a separator between title + * and author like `-`, `—`, `|`, etc., excluding the single quote (`'`) and semicolon. + */ + private val sepToken = "[\\S&&[^\\p{L}]&&[^\\d]]" + + @VisibleForTesting + fun filter(title: String, author: String) = title.replace( + "((\\s*$sepToken\\s*)($author))|(($author)(\\s*$sepToken\\s*))" + .toRegex(RegexOption.IGNORE_CASE), + "" + ) + } +} + +@VisibleForTesting +class FrenchTypography : DocumentedFilter() { + override fun apply( + input: Any?, + args: Map?, + self: PebbleTemplate?, + context: EvaluationContext?, + lineNumber: Int + ): Any? = input?.toString()?.let(::filter) + + override fun getArgumentNames(): List? = null + + override fun generateDocumentation(context: Context) = context.getString( + R.string.fr_typo_documentation, + (leftTokens + rightTokens).joinToString { context.getString(R.string.localised_quotes, it) } + ) + + companion object { + private val leftTokens = listOf("«") + private val rightTokens = listOf("»", "!", "?", ";", ":") + private val leftRegex = "([${leftTokens.joinToString("")}]+)\\s+".toRegex() + private val rightRegex = "\\s+([${rightTokens.joinToString("")}]+)".toRegex() + + @VisibleForTesting + fun filter(input: String) = input.replace(leftRegex, "$1 ").replace(rightRegex, " $1") + } +} + +class ShareIntentTextRenderer(private val item: Item): KoinComponent { + val documentation by lazy { + filters.entries.joinToString(prefix = "
", separator = ",
") { (key, filter) -> + val str = get().getString( + R.string.localised_dict_item, + "$key", + filter.documentation + ) + "
\u2022\t$str" + } + } + + val context + get() = mapOf( + "title" to item.title, + "author" to item.author, + "url" to item.link, + "content" to item.content + ) + + private fun renderSafe(template: String) = runCatching { + val result = StringWriter() + renderer.getTemplate(template).evaluate(result, context) + result.toString() + } + + fun renderOrError(template: String) = renderSafe(template).getOrElse { it.toString() } + fun render(template: String) = renderSafe(template).getOrDefault(item.link) + + companion object { + private val filters: Map = mapOf( + "remove_author" to RemoveAuthorFilter(), + "fr_typo" to FrenchTypography() + ) + + private val renderer = PebbleEngine + .Builder() + .loader(StringLoader()) + .extension(object : AbstractExtension() { + override fun getFilters(): Map = this@Companion.filters + }) + .newLineTrimming(false) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Utils.kt b/app/src/main/java/com/readrops/app/util/Utils.kt index 0f5b91790..43f3f26ab 100644 --- a/app/src/main/java/com/readrops/app/util/Utils.kt +++ b/app/src/main/java/com/readrops/app/util/Utils.kt @@ -1,7 +1,10 @@ package com.readrops.app.util +import android.content.Context +import android.content.Intent import android.graphics.Color import androidx.annotation.ColorInt +import com.readrops.db.entities.Item import java.util.Locale object Utils { @@ -22,4 +25,37 @@ object Utils { Color.alpha(color) / 255.0 ) } + + fun normalizeUrl(url: String): String { + return buildString { + if (!url.contains("https://") && !url.contains("http://")) { + append("https://$url") + } else { + append(url) + } + + if (!url.endsWith("/")) { + append("/") + } + } + } + + fun shareItem( + item: Item, + context: Context, + useCustomShareIntentTpl: Boolean, + customShareIntentTpl: String + ) { + val intentContent = + if(!useCustomShareIntentTpl || customShareIntentTpl.isBlank()) item.link + else ShareIntentTextRenderer(item).render(customShareIntentTpl) + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, intentContent) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + }.also { + context.startActivity(Intent.createChooser(it, null)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/accounterror/AccountError.kt b/app/src/main/java/com/readrops/app/util/accounterror/AccountError.kt new file mode 100644 index 000000000..f79cd4a91 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/accounterror/AccountError.kt @@ -0,0 +1,75 @@ +package com.readrops.app.util.accounterror + +import android.content.Context +import com.readrops.api.utils.exceptions.HttpException +import com.readrops.api.utils.exceptions.LoginFailedException +import com.readrops.api.utils.exceptions.ParseException +import com.readrops.api.utils.exceptions.UnknownFormatException +import com.readrops.app.R +import com.readrops.app.repositories.FeedExistException +import com.readrops.db.entities.account.Account +import com.readrops.db.entities.account.AccountType +import java.io.IOException +import java.net.UnknownHostException + +abstract class AccountError(protected val context: Context) { + + open fun newFeedMessage(exception: Exception): String = genericMessage(exception) + + open fun updateFeedMessage(exception: Exception): String = genericMessage(exception) + + open fun deleteFeedMessage(exception: Exception): String = genericMessage(exception) + + open fun newFolderMessage(exception: Exception): String = genericMessage(exception) + + open fun updateFolderMessage(exception: Exception): String = genericMessage(exception) + + open fun deleteFolderMessage(exception: Exception): String = genericMessage(exception) + + fun genericMessage(exception: Exception) = when (exception) { + is HttpException -> httpMessage(exception) + is UnknownHostException -> context.resources.getString(R.string.unreachable_url) + is NoSuchFileException -> context.resources.getString(R.string.unable_open_file) + is IOException -> context.resources.getString( + R.string.network_failure, + exception.message.orEmpty() + ) + + is ParseException, is UnknownFormatException -> context.resources.getString(R.string.processing_feed_error) + is LoginFailedException -> context.getString(R.string.login_failed) + is FeedExistException -> context.getString(R.string.feed_already_exists) + else -> "${exception.javaClass.simpleName}: ${exception.message}" + } + + protected fun httpMessage(exception: HttpException): String { + return when (exception.code) { + in 400..499 -> { + when (exception.code) { + 400 -> context.resources.getString(R.string.http_error_400) + 401 -> context.resources.getString(R.string.http_error_401) + 403 -> context.resources.getString(R.string.http_error_403) + 404 -> context.resources.getString(R.string.http_error_404) + else -> context.resources.getString(R.string.http_error_4XX, exception.code) + } + } + + in 500..599 -> { + context.resources.getString(R.string.http_error_5XX, exception.code) + } + + else -> context.resources.getString(R.string.http_error, exception.code) + } + } + + companion object { + + fun from(account: Account, context: Context): AccountError = when (account.type) { + AccountType.FRESHRSS, AccountType.GREADER -> GReaderError(context) + AccountType.NEXTCLOUD_NEWS -> NextcloudNewsError(context) + else -> DefaultAccountError(context) + } + + class DefaultAccountError(context: Context) : AccountError(context) + } +} + diff --git a/app/src/main/java/com/readrops/app/util/accounterror/GReaderError.kt b/app/src/main/java/com/readrops/app/util/accounterror/GReaderError.kt new file mode 100644 index 000000000..1dbab1e9a --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/accounterror/GReaderError.kt @@ -0,0 +1,57 @@ +package com.readrops.app.util.accounterror + +import android.content.Context +import com.readrops.api.utils.exceptions.HttpException +import com.readrops.app.R + +class GReaderError(context: Context) : AccountError(context) { + + override fun newFeedMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 400 -> context.getString(R.string.feed_already_exists) + else -> httpMessage(exception) + } + } + else -> genericMessage(exception) + } + + override fun updateFeedMessage(exception: Exception): String { + return newFeedMessage(exception) + } + + override fun deleteFeedMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 400 -> context.resources.getString(R.string.feed_doesnt_exist) + else -> httpMessage(exception) + } + } + else -> genericMessage(exception) + } + + override fun newFolderMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 400 -> context.resources.getString(R.string.folder_already_exists) + else -> httpMessage(exception) + } + } + else -> genericMessage(exception) + } + + override fun updateFolderMessage(exception: Exception): String { + return newFolderMessage(exception) + } + + override fun deleteFolderMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 400 -> context.resources.getString(R.string.folder_doesnt_exist) + else -> httpMessage(exception) + } + } + else -> genericMessage(exception) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/accounterror/NextcloudNewsError.kt b/app/src/main/java/com/readrops/app/util/accounterror/NextcloudNewsError.kt new file mode 100644 index 000000000..2f10afc44 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/accounterror/NextcloudNewsError.kt @@ -0,0 +1,64 @@ +package com.readrops.app.util.accounterror + +import android.content.Context +import com.readrops.api.utils.exceptions.HttpException +import com.readrops.app.R + +class NextcloudNewsError(context: Context) : AccountError(context) { + + override fun newFeedMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 409 -> context.resources.getString(R.string.feed_already_exists) + 422 -> context.getString(R.string.invalid_feed) + else -> httpMessage(exception) + } + } + + else -> genericMessage(exception) + } + + override fun updateFeedMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 404 -> context.resources.getString(R.string.feed_doesnt_exist) + else -> httpMessage(exception) + } + } + + else -> genericMessage(exception) + } + + override fun deleteFeedMessage(exception: Exception): String { + return updateFeedMessage(exception) + } + + override fun newFolderMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 409 -> context.resources.getString(R.string.folder_already_exists) + 422 -> context.resources.getString(R.string.invalid_folder) + else -> httpMessage(exception) + } + } + + else -> genericMessage(exception) + } + + override fun updateFolderMessage(exception: Exception): String = when (exception) { + is HttpException -> { + when (exception.code) { + 404 -> context.resources.getString(R.string.folder_doesnt_exist) + 409 -> context.resources.getString(R.string.folder_already_exists) + 422 -> context.getString(R.string.invalid_folder) + else -> httpMessage(exception) + } + } + + else -> genericMessage(exception) + } + + override fun deleteFolderMessage(exception: Exception): String { + return updateFolderMessage(exception) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/DropdownBox.kt b/app/src/main/java/com/readrops/app/util/components/DropdownBox.kt new file mode 100644 index 000000000..9388e7779 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/DropdownBox.kt @@ -0,0 +1,139 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.readrops.app.util.theme.ShortSpacer + +data class DropdownBoxValue( + val id: Int, + val text: String, + val painter: Painter, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownBox( + expanded: Boolean, + text: String, + label: String, + painter: Painter?, + values: List, + onExpandedChange: (Boolean) -> Unit, + onValueClick: (Int) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = onExpandedChange, + ) { + if (values.isNotEmpty()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + ) { + for (value in values) { + DropdownMenuItem( + text = { Text(text = value.text) }, + onClick = { onValueClick(value.id) }, + leadingIcon = { + Image( + painter = value.painter, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + ) + } + } + } + + OutlinedTextField( + value = text, + label = { Text(text = label) }, + enabled = enabled, + readOnly = true, + onValueChange = {}, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded && enabled) + }, + leadingIcon = { + if (painter != null) { + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + }, + modifier = modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CompactDropdownBox( + expanded: Boolean, + text: String, + values: List, + onExpandedChange: (Boolean) -> Unit, + onValueClick: (Int) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = onExpandedChange + ) { + if (values.isNotEmpty()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + matchTextFieldWidth = false + ) { + for (value in values) { + DropdownMenuItem( + text = { Text(text = value.text) }, + onClick = { onValueClick(value.id) }, + ) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable) + .clickable { onExpandedChange(!expanded) } + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + ShortSpacer() + + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt index df5fba44a..28efa249d 100644 --- a/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt +++ b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage +import coil3.compose.AsyncImage import com.readrops.app.R import com.readrops.app.util.FeedKey diff --git a/app/src/main/java/com/readrops/app/util/components/IconText.kt b/app/src/main/java/com/readrops/app/util/components/IconText.kt index f22b00f02..7fe78c17c 100644 --- a/app/src/main/java/com/readrops/app/util/components/IconText.kt +++ b/app/src/main/java/com/readrops/app/util/components/IconText.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import com.readrops.app.util.extensions.toDp import com.readrops.app.util.theme.spacing -import com.readrops.app.util.toDp @Composable fun BaseText( @@ -31,6 +31,7 @@ fun BaseText( modifier: Modifier = Modifier, color: Color = LocalContentColor.current, spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + maxLines: Int = 1, onClick: (() -> Unit)? = null, leftContent: @Composable () -> Unit ) { @@ -46,7 +47,7 @@ fun BaseText( text = text, style = style, color = color, - maxLines = 1, + maxLines = maxLines, overflow = TextOverflow.Ellipsis ) } @@ -58,9 +59,11 @@ fun IconText( text: String, style: TextStyle, modifier: Modifier = Modifier, + maxLines: Int = 1, color: Color = LocalContentColor.current, tint: Color = LocalContentColor.current, spacing: Dp = MaterialTheme.spacing.veryShortSpacing, + iconSize: Dp = style.toDp(), onClick: (() -> Unit)? = null, ) { BaseText( @@ -69,13 +72,14 @@ fun IconText( color = color, spacing = spacing, modifier = modifier, + maxLines = maxLines, onClick = onClick ) { Icon( painter = icon, tint = tint, contentDescription = null, - modifier = Modifier.size(style.toDp()), + modifier = Modifier.size(iconSize), ) } } diff --git a/app/src/main/java/com/readrops/app/util/components/LoadingButton.kt b/app/src/main/java/com/readrops/app/util/components/LoadingButton.kt new file mode 100644 index 000000000..6e47bf50b --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/LoadingButton.kt @@ -0,0 +1,57 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + + +@Composable +fun LoadingButton( + text: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + modifier = modifier + ) { + if (isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp) + ) + } else { + Text(text = text) + } + } +} + +@Composable +fun LoadingTextButton( + text: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + TextButton( + onClick = onClick, + modifier = modifier + ) { + if (isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + } else { + Text(text = text) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt b/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt deleted file mode 100644 index ea3b255f2..000000000 --- a/app/src/main/java/com/readrops/app/util/components/LoadingTextButton.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.readrops.app.util.components - -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun LoadingTextButton( - text: String, - isLoading: Boolean, - onClick: () -> Unit -) { - TextButton( - onClick = onClick - ) { - if (isLoading) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp) - ) - } else { - Text(text = text) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt b/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt index 57d064265..665f30c1c 100644 --- a/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt +++ b/app/src/main/java/com/readrops/app/util/components/RefreshScreen.kt @@ -2,6 +2,7 @@ package com.readrops.app.util.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -9,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.util.theme.MediumSpacer import com.readrops.app.util.theme.VeryShortSpacer import com.readrops.app.util.theme.spacing @@ -49,4 +51,22 @@ fun RefreshIndicator( overflow = TextOverflow.Ellipsis, ) } +} + +@Composable +fun LoadingScreen( + isRefreshing: Boolean +) { + CenteredColumn { + + if (isRefreshing) { + Text( + text = "Refreshing...", + style = MaterialTheme.typography.labelLarge + ) + + MediumSpacer() + } + CircularProgressIndicator() + } } \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/SwitchText.kt b/app/src/main/java/com/readrops/app/util/components/SwitchText.kt new file mode 100644 index 000000000..39af3c7f3 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/SwitchText.kt @@ -0,0 +1,70 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.readrops.app.util.theme.MediumSpacer +import com.readrops.app.util.theme.spacing + +@Composable +fun SwitchText( + title: String, + subtitle: String?, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!isChecked) } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.mediumSpacing, + vertical = MaterialTheme.spacing.shortSpacing + ) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + maxLines = 2 + ) + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + MediumSpacer() + + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt b/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt index 92c6534f0..e7bba0161 100644 --- a/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt +++ b/app/src/main/java/com/readrops/app/util/components/TextFieldUtils.kt @@ -15,8 +15,8 @@ sealed class TextFieldError { @Composable fun errorText(): String = when (this) { - BadUrl -> stringResource(R.string.not_valid_url) - EmptyField -> stringResource(R.string.field_cant_be_empty) + BadUrl -> stringResource(R.string.wrong_url) + EmptyField -> stringResource(R.string.empty_field) NoRSSFeed -> stringResource(R.string.no_rss_feed_found) NoRSSUrl -> stringResource(R.string.not_valid_rss_feed) UnreachableUrl -> stringResource(R.string.unreachable_url) diff --git a/app/src/main/java/com/readrops/app/util/components/TextHorizontalDivider.kt b/app/src/main/java/com/readrops/app/util/components/TextHorizontalDivider.kt new file mode 100644 index 000000000..e96a68ba1 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/components/TextHorizontalDivider.kt @@ -0,0 +1,27 @@ +package com.readrops.app.util.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import com.readrops.app.util.theme.ShortSpacer + +@Composable +fun TextHorizontalDivider( + text: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall + ) + + ShortSpacer() + + HorizontalDivider() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt index 842167587..8ad3717ba 100644 --- a/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt +++ b/app/src/main/java/com/readrops/app/util/components/dialog/ErrorDialog.kt @@ -3,15 +3,13 @@ package com.readrops.app.util.components.dialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.readrops.app.R -import com.readrops.app.util.ErrorMessage @Composable fun ErrorDialog( - exception: Exception, + error: String, onDismiss: () -> Unit ) { BaseDialog( @@ -20,7 +18,7 @@ fun ErrorDialog( onDismiss = onDismiss ) { Text( - text = ErrorMessage.get(exception, LocalContext.current), + text = error, color = AlertDialogDefaults.textContentColor ) } diff --git a/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt b/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt index c79af6de6..6af5e5ec1 100644 --- a/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt +++ b/app/src/main/java/com/readrops/app/util/components/dialog/TextFieldDialog.kt @@ -10,10 +10,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.readrops.app.R -import com.readrops.app.util.ErrorMessage import com.readrops.app.util.components.LoadingTextButton import com.readrops.app.util.components.TextFieldError import com.readrops.app.util.theme.LargeSpacer @@ -21,7 +19,7 @@ import com.readrops.app.util.theme.LargeSpacer data class TextFieldDialogState( val value: String = "", val textFieldError: TextFieldError? = null, - val exception: Exception? = null, + val error: String? = null, val isLoading: Boolean = false ) { val isTextFieldError @@ -66,9 +64,9 @@ fun TextFieldDialog( supportingText = { Text(text = state.textFieldError?.errorText().orEmpty()) } ) - if (state.exception != null) { + if (state.error != null) { Text( - text = ErrorMessage.get(state.exception, LocalContext.current), + text = state.error, color = MaterialTheme.colorScheme.error ) } diff --git a/app/src/main/java/com/readrops/app/util/extensions/ContextExtensions.kt b/app/src/main/java/com/readrops/app/util/extensions/ContextExtensions.kt new file mode 100644 index 000000000..225775fa9 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/extensions/ContextExtensions.kt @@ -0,0 +1,61 @@ +@file:Suppress("DEPRECATION") + +package com.readrops.app.util.extensions + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.getSystemService +import androidx.core.net.toUri + +fun Context.openUrl(url: String) = startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + +fun Context.openInCustomTab(url: String, theme: String?, color: Color) { + val colorScheme = when (theme) { + "light" -> CustomTabsIntent.COLOR_SCHEME_LIGHT + "dark" -> CustomTabsIntent.COLOR_SCHEME_DARK + else -> CustomTabsIntent.COLOR_SCHEME_SYSTEM + } + + CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams + .Builder() + .setToolbarColor(color.toArgb()) + .build() + ) + .setShareState(CustomTabsIntent.SHARE_STATE_ON) + .setUrlBarHidingEnabled(true) + .setColorScheme(colorScheme) + .build() + .launchUrl(this, url.toUri()) +} + +// TODO arbitrary value, we might want to use windowClasses in the future +fun Context.isTabletUi(): Boolean { + val configuration = resources.configuration + return configuration.smallestScreenWidthDp >= 720 +} + +@Composable +@ReadOnlyComposable +fun isTabletUi(): Boolean = LocalContext.current.isTabletUi() + + +// non depreciated APIs are only available from API 23 +@Suppress("DEPRECATION") +fun Context.isConnected(): Boolean { + val connectivityManager = getSystemService()!! + val networkInfo: NetworkInfo? = connectivityManager.activeNetworkInfo + + return networkInfo?.isConnected == true +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/Extensions.kt b/app/src/main/java/com/readrops/app/util/extensions/Extensions.kt similarity index 66% rename from app/src/main/java/com/readrops/app/util/Extensions.kt rename to app/src/main/java/com/readrops/app/util/extensions/Extensions.kt index 876dddf0e..e9d2a85fa 100644 --- a/app/src/main/java/com/readrops/app/util/Extensions.kt +++ b/app/src/main/java/com/readrops/app/util/extensions/Extensions.kt @@ -1,18 +1,15 @@ -package com.readrops.app.util +package com.readrops.app.util.extensions -import android.content.Context -import android.content.Intent -import android.net.Uri +import androidx.annotation.ColorInt import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils import androidx.work.Data import java.io.Serializable fun TextStyle.toDp(): Dp = fontSize.value.dp -fun Context.openUrl(url: String) = startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - val Data.serializables by lazy { mutableMapOf() } @@ -27,3 +24,7 @@ fun Data.getSerializable(key: String): Serializable? = serializables[key] fun Data.clearSerializables() { serializables.clear() } + +fun Int.canDisplayOnBackground(@ColorInt background: Int, threshold: Float = 1.75f): Boolean = + ColorUtils.calculateContrast(this, background) > threshold + diff --git a/app/src/main/java/com/readrops/app/util/extensions/FeedExtensions.kt b/app/src/main/java/com/readrops/app/util/extensions/FeedExtensions.kt new file mode 100644 index 000000000..ac6c0bf61 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/extensions/FeedExtensions.kt @@ -0,0 +1,18 @@ +package com.readrops.app.util.extensions + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.readrops.db.entities.Feed +import com.readrops.db.pojo.ItemWithFeed + +fun Feed.getColorOrNull(): Color? = Color(color).takeIf { color != 0 } + +@Composable +fun ItemWithFeed.displayColor(background: Int): Color { + return if (color != 0 && color.canDisplayOnBackground(background)) { + Color(color) + } else { + MaterialTheme.colorScheme.primary + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/extensions/LazyPagingItemsExtensions.kt b/app/src/main/java/com/readrops/app/util/extensions/LazyPagingItemsExtensions.kt new file mode 100644 index 000000000..fe1e6a4ac --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/extensions/LazyPagingItemsExtensions.kt @@ -0,0 +1,16 @@ +package com.readrops.app.util.extensions + +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems + +fun LazyPagingItems.isLoading(): Boolean { + return loadState.refresh is LoadState.Loading && itemCount == 0 +} + +fun LazyPagingItems.isError(): Boolean { + return loadState.append is LoadState.Error //|| loadState.refresh is LoadState.Error +} + +fun LazyPagingItems.isNotEmpty(): Boolean { + return itemCount > 0 +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/theme/BlackWhiteColor.kt b/app/src/main/java/com/readrops/app/util/theme/BlackWhiteColor.kt new file mode 100644 index 000000000..beb9854ce --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/theme/BlackWhiteColor.kt @@ -0,0 +1,75 @@ +package com.readrops.app.util.theme + +import androidx.compose.ui.graphics.Color + +val primaryBlackWhiteLight = Color(0xFF000000) +val onPrimaryBlackWhiteLight = Color(0xFFFFFFFF) +val primaryContainerBlackWhiteLight = Color(0xFFE2E2E2) +val onPrimaryContainerBlackWhiteLight = Color(0xFF848484) +val secondaryBlackWhiteLight = Color(0xFF464647) +val onSecondaryBlackWhiteLight = Color(0xFFFFFFFF) +val secondaryContainerBlackWhiteLight = Color(0xFF000000) +val onSecondaryContainerBlackWhiteLight = Color(0xFFD9D8D7) +val tertiaryBlackWhiteLight = Color(0xFF000000) +val onTertiaryBlackWhiteLight = Color(0xFFFFFFFF) +val tertiaryContainerBlackWhiteLight = Color(0xFF1B1B1B) +val onTertiaryContainerBlackWhiteLight = Color(0xFF848484) +val errorBlackWhiteLight = Color(0xFF464647) +val onErrorBlackWhiteLight = Color(0xFFFFFFFF) +val errorContainerBlackWhiteLight = Color(0xFF000000) +val onErrorContainerBlackWhiteLight = Color(0xFFD9D8D7) +val backgroundBlackWhiteLight = Color(0xFFFFFFFF) +val onBackgroundBlackWhiteLight = Color(0xFF1B1B1B) +val surfaceBlackWhiteLight = Color(0xFFFCF8F8) +val onSurfaceBlackWhiteLight = Color(0xFF1C1B1B) +val surfaceVariantBlackWhiteLight = Color(0xFFE0E3E3) +val onSurfaceVariantBlackWhiteLight = Color(0xFF444748) +val outlineBlackWhiteLight = Color(0xFF747878) +val outlineVariantBlackWhiteLight = Color(0xFFC4C7C8) +val scrimBlackWhiteLight = Color(0xFF000000) +val inverseSurfaceBlackWhiteLight = Color(0xFF313030) +val inverseOnSurfaceBlackWhiteLight = Color(0xFFE2E2E2) +val inversePrimaryBlackWhiteLight = Color(0xFFC6C6C6) +val surfaceDimBlackWhiteLight = Color(0xFFE2E2E2) +val surfaceBrightBlackWhiteLight = Color(0xFFFCF8F8) +val surfaceContainerLowestBlackWhiteLight = Color(0xFFFFFFFF) +val surfaceContainerLowBlackWhiteLight = Color(0xFFE2E2E2) +val surfaceContainerBlackWhiteLight = Color(0xFFFFFFFF) +val surfaceContainerHighBlackWhiteLight = Color(0xFFE2E2E2) +val surfaceContainerHighestBlackWhiteLight = Color(0xFFFFFFFF) + +val primaryBlackWhiteDark = Color(0xFFC6C6C6) +val onPrimaryBlackWhiteDark = Color(0xFF303030) +val primaryContainerBlackWhiteDark = Color(0xFF000000) +val onPrimaryContainerBlackWhiteDark = Color(0xFF969696) +val secondaryBlackWhiteDark = Color(0xFFC7C6C6) +val onSecondaryBlackWhiteDark = Color(0xFF303030) +val secondaryContainerBlackWhiteDark = Color(0xFF000000) +val onSecondaryContainerBlackWhiteDark = Color(0xFFD9D8D7) +val tertiaryBlackWhiteDark = Color(0xFFC6C6C6) +val onTertiaryBlackWhiteDark = Color(0xFF303030) +val tertiaryContainerBlackWhiteDark = Color(0xFF000000) +val onTertiaryContainerBlackWhiteDark = Color(0xFFFFFFFF) +val errorBlackWhiteDark = Color(0xFFC7C6C6) +val onErrorBlackWhiteDark = Color(0xFF303030) +val errorContainerBlackWhiteDark = Color(0xFF000000) +val onErrorContainerBlackWhiteDark = Color(0xFFD9D8D7) +val backgroundBlackWhiteDark = Color(0xFF000000) +val onBackgroundBlackWhiteDark = Color(0xFFE2E2E2) +val surfaceBlackWhiteDark = Color(0xFF000000) +val onSurfaceBlackWhiteDark = Color(0xFFE5E2E1) +val surfaceVariantBlackWhiteDark = Color(0xFF000000) +val onSurfaceVariantBlackWhiteDark = Color(0xFFC4C7C8) +val outlineBlackWhiteDark = Color(0xFF8E9192) +val outlineVariantBlackWhiteDark = Color(0xFF444748) +val scrimBlackWhiteDark = Color(0xFF000000) +val inverseSurfaceBlackWhiteDark = Color(0xFFE5E2E1) +val inverseOnSurfaceBlackWhiteDark = Color(0xFF313030) +val inversePrimaryBlackWhiteDark = Color(0xFF000000) +val surfaceDimBlackWhiteDark = Color(0xFF000000) +val surfaceBrightBlackWhiteDark = Color(0xFF000000) +val surfaceContainerLowestBlackWhiteDark = Color(0xFF0E0E0E) +val surfaceContainerLowBlackWhiteDark = Color(0xFF1C1B1B) +val surfaceContainerBlackWhiteDark = Color(0xFF000000) +val surfaceContainerHighBlackWhiteDark = Color(0xFF2A2A2A) +val surfaceContainerHighestBlackWhiteDark = Color(0xFF000000) diff --git a/app/src/main/java/com/readrops/app/util/theme/Color.kt b/app/src/main/java/com/readrops/app/util/theme/Color.kt index 30d0e56d0..0ddc0c603 100644 --- a/app/src/main/java/com/readrops/app/util/theme/Color.kt +++ b/app/src/main/java/com/readrops/app/util/theme/Color.kt @@ -2,132 +2,74 @@ package com.readrops.app.util.theme import androidx.compose.ui.graphics.Color -// Material Design Light -val md_theme_light_primary = Color(0xFF0062A2) -val md_theme_light_onPrimary = Color(0xFFFFFFFF) -val md_theme_light_primaryContainer = Color(0xFFD1E4FF) -val md_theme_light_onPrimaryContainer = Color(0xFF001D35) -val md_theme_light_secondary = Color(0xFFA43D00) -val md_theme_light_onSecondary = Color(0xFFFFFFFF) -val md_theme_light_secondaryContainer = Color(0xFFFFDBCD) -val md_theme_light_onSecondaryContainer = Color(0xFF360F00) -val md_theme_light_tertiary = Color(0xFF006D3D) -val md_theme_light_onTertiary = Color(0xFFFFFFFF) -val md_theme_light_tertiaryContainer = Color(0xFF97F7B7) -val md_theme_light_onTertiaryContainer = Color(0xFF00210F) -val md_theme_light_error = Color(0xFFBA1A1A) -val md_theme_light_errorContainer = Color(0xFFFFDAD6) -val md_theme_light_onError = Color(0xFFFFFFFF) -val md_theme_light_onErrorContainer = Color(0xFF410002) -val md_theme_light_background = Color(0xFFF8FDFF) -val md_theme_light_onBackground = Color(0xFF001F25) -val md_theme_light_surface = Color(0xFFF8FDFF) -val md_theme_light_onSurface = Color(0xFF001F25) -val md_theme_light_surfaceVariant = Color(0xFFDFE2EB) -val md_theme_light_onSurfaceVariant = Color(0xFF42474E) -val md_theme_light_outline = Color(0xFF73777F) -val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF) -val md_theme_light_inverseSurface = Color(0xFF00363F) -val md_theme_light_inversePrimary = Color(0xFF9DCAFF) -val md_theme_light_shadow = Color(0xFF000000) -val md_theme_light_surfaceTint = Color(0xFF0062A2) -val md_theme_light_outlineVariant = Color(0xFFC3C7CF) -val md_theme_light_scrim = Color(0xFF000000) +val primaryLight = Color(0xFF0062A2) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD1E4FF) +val onPrimaryContainerLight = Color(0xFF001D35) +val secondaryLight = Color(0xFFA43D00) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFFFDBCD) +val onSecondaryContainerLight = Color(0xFF360F00) +val tertiaryLight = Color(0xFF006D3D) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFF97F7B7) +val onTertiaryContainerLight = Color(0xFF00210F) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFF8FDFF) +val onBackgroundLight = Color(0xFF001F25) +val surfaceLight = Color(0xFFF8FDFF) +val onSurfaceLight = Color(0xFF001F25) +val surfaceVariantLight = Color(0xFFDFE2EB) +val onSurfaceVariantLight = Color(0xFF42474E) +val outlineLight = Color(0xFF73777F) +val outlineVariantLight = Color(0xFFC3C7CF) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF00363F) +val inverseOnSurfaceLight = Color(0xFFD6F6FF) +val inversePrimaryLight = Color(0xFF9DCAFF) +val surfaceDimLight = Color(0xFFD8DAE0) +val surfaceBrightLight = Color(0xFFF8F9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF2F3F9) +val surfaceContainerLight = Color(0xFFECEEF4) +val surfaceContainerHighLight = Color(0xFFE6E8EE) +val surfaceContainerHighestLight = Color(0xFFE1E2E8) -// Material Design Dark -val md_theme_dark_primary = Color(0xFF9DCAFF) -val md_theme_dark_onPrimary = Color(0xFF003257) -val md_theme_dark_primaryContainer = Color(0xFF00497C) -val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF) -val md_theme_dark_secondary = Color(0xFFFFB597) -val md_theme_dark_onSecondary = Color(0xFF581D00) -val md_theme_dark_secondaryContainer = Color(0xFF7D2D00) -val md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCD) -val md_theme_dark_tertiary = Color(0xFF7BDA9C) -val md_theme_dark_onTertiary = Color(0xFF00391D) -val md_theme_dark_tertiaryContainer = Color(0xFF00522C) -val md_theme_dark_onTertiaryContainer = Color(0xFF97F7B7) -val md_theme_dark_error = Color(0xFFFFB4AB) -val md_theme_dark_errorContainer = Color(0xFF93000A) -val md_theme_dark_onError = Color(0xFF690005) -val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -val md_theme_dark_background = Color(0xFF001F25) -val md_theme_dark_onBackground = Color(0xFFA6EEFF) -val md_theme_dark_surface = Color(0xFF001F25) -val md_theme_dark_onSurface = Color(0xFFA6EEFF) -val md_theme_dark_surfaceVariant = Color(0xFF42474E) -val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF) -val md_theme_dark_outline = Color(0xFF8D9199) -val md_theme_dark_inverseOnSurface = Color(0xFF001F25) -val md_theme_dark_inverseSurface = Color(0xFFA6EEFF) -val md_theme_dark_inversePrimary = Color(0xFF0062A2) -val md_theme_dark_shadow = Color(0xFF000000) -val md_theme_dark_surfaceTint = Color(0xFF9DCAFF) -val md_theme_dark_outlineVariant = Color(0xFF42474E) -val md_theme_dark_scrim = Color(0xFF000000) - -// Black & White Light -val bw_theme_light_primary = Color(0xFF000000) -val bw_theme_light_onPrimary = Color(0xFFFFFFFF) -val bw_theme_light_primaryContainer = Color(0xFF262626) -val bw_theme_light_onPrimaryContainer = Color(0xFFB1B1B1) -val bw_theme_light_secondary = Color(0xFF5E5E5E) -val bw_theme_light_onSecondary = Color(0xFFFFFFFF) -val bw_theme_light_secondaryContainer = Color(0xFFE6E6E6) -val bw_theme_light_onSecondaryContainer = Color(0xFF4A4A4A) -val bw_theme_light_tertiary = Color(0xFF000000) -val bw_theme_light_onTertiary = Color(0xFFFFFFFF) -val bw_theme_light_tertiaryContainer = Color(0xFF262626) -val bw_theme_light_onTertiaryContainer = Color(0xFFB1B1B1) -val bw_theme_light_error = Color(0xFF5E5E5E) -val bw_theme_light_onError = Color(0xFFFFFFFF) -val bw_theme_light_errorContainer = Color(0xFFE6E6E6) -val bw_theme_light_onErrorContainer = Color(0xFF000000) -val bw_theme_light_background = Color(0xFFF9F9F9) -val bw_theme_light_onBackground = Color(0xFF1B1B1B) -val bw_theme_light_surface = Color(0xFFF9F9F9) -val bw_theme_light_onSurface = Color(0xFF1B1B1B) -val bw_theme_light_surfaceVariant = Color(0xFFFFFFFF) -val bw_theme_light_onSurfaceVariant = Color(0xFF4C4546) -val bw_theme_light_outline = Color(0xFF7E7576) -val bw_theme_light_inverseOnSurface = Color(0xFFF1F1F1) -val bw_theme_light_inverseSurface = Color(0xFF303030) -val bw_theme_light_inversePrimary = Color(0xFFC6C6C6) -val bw_theme_light_shadow = Color(0xFF4C4546) -val bw_theme_light_surfaceTint = Color(0xFFF3F3F3) -val bw_theme_light_outlineVariant = Color(0xFFF3F3F3) -val bw_theme_light_scrim = Color(0xFF000000) - -// Black & White Dark -val bw_theme_dark_primary = Color(0xFFC6C6C6) -val bw_theme_dark_onPrimary = Color(0xFF303030) -val bw_theme_dark_onPrimaryContainer = Color(0xFF969696) -val bw_theme_dark_primaryContainer = Color(0xFF000000) -val bw_theme_dark_secondary = Color(0xFFC6C6C6) -val bw_theme_dark_onSecondary = Color(0xFF303030) -val bw_theme_dark_secondaryContainer = Color(0xFF3D3D3D) -val bw_theme_dark_onSecondaryContainer = Color(0xFFD1D1D1) -val bw_theme_dark_tertiary = Color(0xFFC6C6C6) -val bw_theme_dark_onTertiary = Color(0xFF303030) -val bw_theme_dark_tertiaryContainer = Color(0xFF000000) -val bw_theme_dark_onTertiaryContainer = Color(0xFFFFFFFF) -val bw_theme_dark_error = Color(0xFF5E5E5E) -val bw_theme_dark_onError = Color(0xFF000000) -val bw_theme_dark_errorContainer = Color(0xFF0A0A0A) -val bw_theme_dark_onErrorContainer = Color(0xFFFFFFFF) -val bw_theme_dark_background = Color(0xFF131313) -val bw_theme_dark_onBackground = Color(0xFFFFFFFF) -val bw_theme_dark_surface = Color(0xFF131313) -val bw_theme_dark_onSurface = Color(0xFFE2E2E2) -val bw_theme_dark_surfaceVariant = Color(0xFF131313) -val bw_theme_dark_onSurfaceVariant = Color(0xFFCFC4C5) -val bw_theme_dark_outline = Color(0xFF988E90) -val bw_theme_dark_inverseSurface = Color(0xFFE2E2E2) -val bw_theme_dark_inverseOnSurface = Color(0xFF303030) -val bw_theme_dark_inversePrimary = Color(0xFFFFFFFF) -val bw_theme_dark_shadow = Color(0xFF4C4546) -val bw_theme_dark_surfaceTint = Color(0xFF0A0A0A) -val bw_theme_dark_outlineVariant = Color(0xFF4C4546) -val bw_theme_dark_scrim = Color(0xFF000000) - -val seed = Color(0xFF0072BC) +val primaryDark = Color(0xFF9DCAFF) +val onPrimaryDark = Color(0xFF003257) +val primaryContainerDark = Color(0xFF00497C) +val onPrimaryContainerDark = Color(0xFFD1E4FF) +val secondaryDark = Color(0xFFFFB597) +val onSecondaryDark = Color(0xFF581D00) +val secondaryContainerDark = Color(0xFF7D2D00) +val onSecondaryContainerDark = Color(0xFFFFDBCD) +val tertiaryDark = Color(0xFF7BDA9C) +val onTertiaryDark = Color(0xFF00391D) +val tertiaryContainerDark = Color(0xFF00522C) +val onTertiaryContainerDark = Color(0xFF97F7B7) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF001F25) +val onBackgroundDark = Color(0xFFA6EEFF) +val surfaceDark = Color(0xFF001F25) +val onSurfaceDark = Color(0xFFA6EEFF) +val surfaceVariantDark = Color(0xFF42474E) +val onSurfaceVariantDark = Color(0xFFC3C7CF) +val outlineDark = Color(0xFF8D9199) +val outlineVariantDark = Color(0xFF42474E) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFA6EEFF) +val inverseOnSurfaceDark = Color(0xFF001F25) +val inversePrimaryDark = Color(0xFF0062A2) +val surfaceDimDark = Color(0xFF101418) +val surfaceBrightDark = Color(0xFF36393E) +val surfaceContainerLowestDark = Color(0xFF0B0E13) +val surfaceContainerLowDark = Color(0xFF191C20) +val surfaceContainerDark = Color(0xFF1D2024) +val surfaceContainerHighDark = Color(0xFF272A2F) +val surfaceContainerHighestDark = Color(0xFF32353A) diff --git a/app/src/main/java/com/readrops/app/util/theme/Theme.kt b/app/src/main/java/com/readrops/app/util/theme/Theme.kt index f72b80054..5389f5d29 100644 --- a/app/src/main/java/com/readrops/app/util/theme/Theme.kt +++ b/app/src/main/java/com/readrops/app/util/theme/Theme.kt @@ -7,155 +7,178 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import com.readrops.app.R -private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - primaryContainer = md_theme_light_primaryContainer, - onPrimaryContainer = md_theme_light_onPrimaryContainer, - secondary = md_theme_light_secondary, - onSecondary = md_theme_light_onSecondary, - secondaryContainer = md_theme_light_secondaryContainer, - onSecondaryContainer = md_theme_light_onSecondaryContainer, - tertiary = md_theme_light_tertiary, - onTertiary = md_theme_light_onTertiary, - tertiaryContainer = md_theme_light_tertiaryContainer, - onTertiaryContainer = md_theme_light_onTertiaryContainer, - error = md_theme_light_error, - errorContainer = md_theme_light_errorContainer, - onError = md_theme_light_onError, - onErrorContainer = md_theme_light_onErrorContainer, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, - surface = md_theme_light_surface, - onSurface = md_theme_light_onSurface, - surfaceVariant = md_theme_light_surfaceVariant, - onSurfaceVariant = md_theme_light_onSurfaceVariant, - outline = md_theme_light_outline, - inverseOnSurface = md_theme_light_inverseOnSurface, - inverseSurface = md_theme_light_inverseSurface, - inversePrimary = md_theme_light_inversePrimary, - surfaceTint = md_theme_light_surfaceTint, - outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, ) - -private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - secondary = md_theme_dark_secondary, - onSecondary = md_theme_dark_onSecondary, - secondaryContainer = md_theme_dark_secondaryContainer, - onSecondaryContainer = md_theme_dark_onSecondaryContainer, - tertiary = md_theme_dark_tertiary, - onTertiary = md_theme_dark_onTertiary, - tertiaryContainer = md_theme_dark_tertiaryContainer, - onTertiaryContainer = md_theme_dark_onTertiaryContainer, - error = md_theme_dark_error, - errorContainer = md_theme_dark_errorContainer, - onError = md_theme_dark_onError, - onErrorContainer = md_theme_dark_onErrorContainer, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, - surface = md_theme_dark_surface, - onSurface = md_theme_dark_onSurface, - surfaceVariant = md_theme_dark_surfaceVariant, - onSurfaceVariant = md_theme_dark_onSurfaceVariant, - outline = md_theme_dark_outline, - inverseOnSurface = md_theme_dark_inverseOnSurface, - inverseSurface = md_theme_dark_inverseSurface, - inversePrimary = md_theme_dark_inversePrimary, - surfaceTint = md_theme_dark_surfaceTint, - outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, ) -private val BlackWhiteLightColors = lightColorScheme( - primary = bw_theme_light_primary, - onPrimary = bw_theme_light_onPrimary, - primaryContainer = bw_theme_light_primaryContainer, - onPrimaryContainer = bw_theme_light_onPrimaryContainer, - secondary = bw_theme_light_secondary, - onSecondary = bw_theme_light_onSecondary, - secondaryContainer = bw_theme_light_secondaryContainer, - onSecondaryContainer = bw_theme_light_onSecondaryContainer, - tertiary = bw_theme_light_tertiary, - onTertiary = bw_theme_light_onTertiary, - tertiaryContainer = bw_theme_light_tertiaryContainer, - onTertiaryContainer = bw_theme_light_onTertiaryContainer, - error = bw_theme_light_error, - errorContainer = bw_theme_light_errorContainer, - onError = bw_theme_light_onError, - onErrorContainer = bw_theme_light_onErrorContainer, - background = bw_theme_light_background, - onBackground = bw_theme_light_onBackground, - surface = bw_theme_light_surface, - onSurface = bw_theme_light_onSurface, - surfaceVariant = bw_theme_light_surfaceVariant, - onSurfaceVariant = bw_theme_light_onSurfaceVariant, - outline = bw_theme_light_outline, - inverseOnSurface = bw_theme_light_inverseOnSurface, - inverseSurface = bw_theme_light_inverseSurface, - inversePrimary = bw_theme_light_inversePrimary, - surfaceTint = bw_theme_light_surfaceTint, - outlineVariant = bw_theme_light_outlineVariant, - scrim = bw_theme_light_scrim, +private val BlackWhiteLightScheme = lightColorScheme( + primary = primaryBlackWhiteLight, + onPrimary = onPrimaryBlackWhiteLight, + primaryContainer = primaryContainerBlackWhiteLight, + onPrimaryContainer = onPrimaryContainerBlackWhiteLight, + secondary = secondaryBlackWhiteLight, + onSecondary = onSecondaryBlackWhiteLight, + secondaryContainer = secondaryContainerBlackWhiteLight, + onSecondaryContainer = onSecondaryContainerBlackWhiteLight, + tertiary = tertiaryBlackWhiteLight, + onTertiary = onTertiaryBlackWhiteLight, + tertiaryContainer = tertiaryContainerBlackWhiteLight, + onTertiaryContainer = onTertiaryContainerBlackWhiteLight, + error = errorBlackWhiteLight, + onError = onErrorBlackWhiteLight, + errorContainer = errorContainerBlackWhiteLight, + onErrorContainer = onErrorContainerBlackWhiteLight, + background = backgroundBlackWhiteLight, + onBackground = onBackgroundBlackWhiteLight, + surface = surfaceBlackWhiteLight, + onSurface = onSurfaceBlackWhiteLight, + surfaceVariant = surfaceVariantBlackWhiteLight, + onSurfaceVariant = onSurfaceVariantBlackWhiteLight, + outline = outlineBlackWhiteLight, + outlineVariant = outlineVariantBlackWhiteLight, + scrim = scrimBlackWhiteLight, + inverseSurface = inverseSurfaceBlackWhiteLight, + inverseOnSurface = inverseOnSurfaceBlackWhiteLight, + inversePrimary = inversePrimaryBlackWhiteLight, + surfaceDim = surfaceDimBlackWhiteLight, + surfaceBright = surfaceBrightBlackWhiteLight, + surfaceContainerLowest = surfaceContainerLowestBlackWhiteLight, + surfaceContainerLow = surfaceContainerLowBlackWhiteLight, + surfaceContainer = surfaceContainerBlackWhiteLight, + surfaceContainerHigh = surfaceContainerHighBlackWhiteLight, + surfaceContainerHighest = surfaceContainerHighestBlackWhiteLight, ) -private val BlackWhiteDarkColors = lightColorScheme( - primary = bw_theme_dark_primary, - onPrimary = bw_theme_dark_onPrimary, - primaryContainer = bw_theme_dark_primaryContainer, - onPrimaryContainer = bw_theme_dark_onPrimaryContainer, - secondary = bw_theme_dark_secondary, - onSecondary = bw_theme_dark_onSecondary, - secondaryContainer = bw_theme_dark_secondaryContainer, - onSecondaryContainer = bw_theme_dark_onSecondaryContainer, - tertiary = bw_theme_dark_tertiary, - onTertiary = bw_theme_dark_onTertiary, - tertiaryContainer = bw_theme_dark_tertiaryContainer, - onTertiaryContainer = bw_theme_dark_onTertiaryContainer, - error = bw_theme_dark_error, - errorContainer = bw_theme_dark_errorContainer, - onError = bw_theme_dark_onError, - onErrorContainer = bw_theme_dark_onErrorContainer, - background = bw_theme_dark_background, - onBackground = bw_theme_dark_onBackground, - surface = bw_theme_dark_surface, - onSurface = bw_theme_dark_onSurface, - surfaceVariant = bw_theme_dark_surfaceVariant, - onSurfaceVariant = bw_theme_dark_onSurfaceVariant, - outline = bw_theme_dark_outline, - inverseOnSurface = bw_theme_dark_inverseOnSurface, - inverseSurface = bw_theme_dark_inverseSurface, - inversePrimary = bw_theme_dark_inversePrimary, - surfaceTint = bw_theme_dark_surfaceTint, - outlineVariant = bw_theme_dark_outlineVariant, - scrim = bw_theme_dark_scrim, +private val BlackWhiteDarkScheme = lightColorScheme( + primary = primaryBlackWhiteDark, + onPrimary = onPrimaryBlackWhiteDark, + primaryContainer = primaryContainerBlackWhiteDark, + onPrimaryContainer = onPrimaryContainerBlackWhiteDark, + secondary = secondaryBlackWhiteDark, + onSecondary = onSecondaryBlackWhiteDark, + secondaryContainer = secondaryContainerBlackWhiteDark, + onSecondaryContainer = onSecondaryContainerBlackWhiteDark, + tertiary = tertiaryBlackWhiteDark, + onTertiary = onTertiaryBlackWhiteDark, + tertiaryContainer = tertiaryContainerBlackWhiteDark, + onTertiaryContainer = onTertiaryContainerBlackWhiteDark, + error = errorBlackWhiteDark, + onError = onErrorBlackWhiteDark, + errorContainer = errorContainerBlackWhiteDark, + onErrorContainer = onErrorContainerBlackWhiteDark, + background = backgroundBlackWhiteDark, + onBackground = onBackgroundBlackWhiteDark, + surface = surfaceBlackWhiteDark, + onSurface = onSurfaceBlackWhiteDark, + surfaceVariant = surfaceVariantBlackWhiteDark, + onSurfaceVariant = onSurfaceVariantBlackWhiteDark, + outline = outlineBlackWhiteDark, + outlineVariant = outlineVariantBlackWhiteDark, + scrim = scrimBlackWhiteDark, + inverseSurface = inverseSurfaceBlackWhiteDark, + inverseOnSurface = inverseOnSurfaceBlackWhiteDark, + inversePrimary = inversePrimaryBlackWhiteDark, + surfaceDim = surfaceDimBlackWhiteDark, + surfaceBright = surfaceBrightBlackWhiteDark, + surfaceContainerLowest = surfaceContainerLowestBlackWhiteDark, + surfaceContainerLow = surfaceContainerLowBlackWhiteDark, + surfaceContainer = surfaceContainerBlackWhiteDark, + surfaceContainerHigh = surfaceContainerHighBlackWhiteDark, + surfaceContainerHighest = surfaceContainerHighestBlackWhiteDark, ) @Composable fun ReadropsTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), - themeColourScheme: String = "readrops", + themeColorScheme: String = "readrops", content: @Composable () -> Unit ) { - val colors = when(themeColourScheme) { + val colors = when(themeColorScheme) { "blackwhite" -> { if (!useDarkTheme) { - BlackWhiteLightColors + BlackWhiteLightScheme } else { - BlackWhiteDarkColors + BlackWhiteDarkScheme } } else -> { if (!useDarkTheme) { - LightColors + lightScheme } else { - DarkColors + darkScheme } } } diff --git a/app/src/main/res/drawable/ic_account.xml b/app/src/main/res/drawable/ic_account.xml new file mode 100644 index 000000000..9900a7eee --- /dev/null +++ b/app/src/main/res/drawable/ic_account.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..21f177828 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reset_color.xml b/app/src/main/res/drawable/ic_reset_color.xml new file mode 100644 index 000000000..ca645057f --- /dev/null +++ b/app/src/main/res/drawable/ic_reset_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 000000000..123a5afc8 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 000000000..463d9bc0e --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9930fea39..ff4fc469e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3,15 +3,12 @@ Favoriten Browser-Ansicht Synchronisieren - Bildunterschrift anzeigen Zurück Öffnen Damit die Benachrichtigungen angezeigt werden, muss die automatische Synchronisierung aktiviert sein. -\nWollen Sie die Einstellungen öffnen\? +\nWollen Sie die Einstellungen öffnen?
Automatische Synchronisierung ist deaktiviert - Alle Feed-Benachrichtigungen aktivieren Benachrichtigungen - Benachrichtigungen einschalten %1$s neue Artikel Synchronisierung der Konten Täglich @@ -23,53 +20,32 @@ 30 Min. Manuell Automatische Synchronisierung - Zum Herunterladen des Bildes ist eine Speichererlaubnis erforderlich Neuer Feed - Exportieren von Feeds und Ordnern Dunkel Hell Thema Farbschema Readrops - Black White + BlackWhite Bild teilen Bild herunterladen Bildoptionen - Oder - Berechtigungen - Erneut versuchen - Der Export von Abonnements erfordert die Genehmigung für externen Speicher OPML-Export OPML-Import - Bei der Verarbeitung der Datei ist ein Fehler aufgetreten - Dieser Vorgang kann viel Zeit in Anspruch nehmen, da jeder Feed abgefragt werden muss. - Verarbeitung der OPML-Datei OPML-Import/Export URL teilen - Aktualisieren - Externer Browser - Webview + Externer Webbrowser Artikel öffnen in - Feedfarben neu laden Global - Feedfarben Abrufen von Feedfarben - Fehler für Feed %1$s - Kein Feed gefunden - Keine Artikel Löschen - %1$s Feed - %1$s Feeds Lokal - Unbegrenzt - Maximale Anzahl von Artikeln pro Feed Neues Konto Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten Älteste zuerst Neueste zuerst Anmeldeinformationen Der Ordner existiert nicht auf dem Server - Falsches Format für den neuen Ordner Der Ordner existiert bereits Ein Fehler ist aufgetreten Der Feed %1$s existiert nicht auf dem Server @@ -79,62 +55,141 @@ Ordner löschen\? Ordner bearbeiten Feeds - Ordner - Feeds und Ordner verwalten Konto - Feeds und Ordner Wählen Sie ein Konto Keine Feeds Konto hinzufügen - Kontoeinstellungen Dies ist Ihr FreshRSS-API-Passwort (Konfiguration > Profil) Passwort Identifikationsdaten Kontobezeichnung Konto-URL - Alle auswählen Als gelesen markieren Als ungelesen markieren - Filtern Gelesene Artikel anzeigen - Später zu lesen Artikel Unbekannter Fehler für Feed %1$s Falsches Format für Feed %1$s Fehler beim Analysieren des Feeds %1$s Netzwerkfehler beim Zugriff auf den Feed %1$s Feed %1$s erfolgreich hinzugefügt - Ergebnisse - Aktualisierung des Feeds: %1$s - Laden - Feed löschen\? + Feed löschen? Keine Ordner Abbrechen - Ordner Feed bearbeiten Feedname - Feed-Ordner Ordner hinzufügen URL öffnen Artikel teilen %1$s Min. 1 Min. - Weniger als eine Minute - von %1$s + <1 Minute Unbekannter Host Verbindungsfehler - Keine Feed-URL gefunden Eingabe ist keine gültige URL Feld darf nicht leer sein - Validieren + OK Feed-URL Feed hinzufügen - Über Einstellungen - Ordner hinzufügen - Feed hinzufügen - Menü schließen - Menü öffnen - Nicht gelesene Artikel - Zum Lesen - \ No newline at end of file + Feed hinzufügen + Alle Artikel als gelesen markieren + Möchtest Du wirklich alle Artikel als gelesen markieren? + Datum + Kennzeichnung + Aufsteigend + Absteigend + Sortiere nach + Mit Richtung + Frage mich nicht noch einmal für weitere Feeds + Standardkategorie + + Für den folgenden Feed ist ein Fehler aufgetreten: + Bei den folgenden Feeds sind einige Fehler aufgetreten: + + Wähle einen Ordner + (%1$s ausgewählt) + %1$s ausgewählte Feeds hinzufügen + Gib eine URL an + Feed existiert bereits + Ungültiger Ordner + Ungültiger Feed + Beim Herunterladen des Bildes ist ein Fehler aufgetreten + Benachrichtigungen aktivieren + Kontobenachrichtigungen sind derzeit deaktiviert + Öffne den Feed in + Lokale Ansicht + Externe Ansicht + App unter der GPLv3-Lizenz veröffentlicht + Feeds ohne neue Artikel ausblenden + Einträge während des Scrollens als gelesen markieren + Filter + Details + Eine Spende machen + Feeds, die keine ungelesenen Artikel mehr enthalten, werden in der Seitenleiste versteckt + Regulär + Groß + Eintragsgröße + Akku-Optimierung für diese App bereits deaktiviert + Kein Artikel + Kein RSS-Feed gefunden + Wenn dieser Fehler angezeigt wird bedeutet das, dass die App auf einen unerwarteten Fehler gestoßen ist. Sofern möglich bitte den Fehler auf Github melden. + Den Fehler auf Github melden + Fehler in die Zwischenablage kopieren + Wenn du meine Arbeit nützlich findest und mich unterstützen möchtest, kannst du mir eine Spende zukommen lassen. + Datei heruntergeladen! + Möchten Sie den Ordner %1$s löschen? Alle verknüpften Feeds werden ebenfalls gelöscht. + Systembenachrichtigungen sind derzeit deaktiviert. Hier klicken, um sie zu aktivieren + RSS-Feeds enthalten immer alte Artikel, die du wahrscheinlich nicht in deiner Timeline sehen wirst, wenn du sie nach Datum ordnest. Mit der Sortierung nach Artikelkennzeichnung kannst du alle neu eingefügten Artikel unabhängig von ihrem Datum anzeigen. + Die API unterstützt keine Änderung der Feed-URL + Update + HTTP-Fehler 404, URL nicht gefunden + Synchronisationsfehler + Systemthema + Neue Beiträge + An error occurred + Feed nicht erreichbar, HTTP-Fehler %1$s + Netzwerkfehler: %1$s + Fehler bei der Verarbeitung des Feeds + URL nicht erreichbar + Datei kann nicht geöffnet werden + HTTP-Fehler 400, bitte die Server-URL prüfen + HTTP-Fehler 401, bitte die Anmeldedaten überprüfen + HTTP-Fehler 403, Zugriff untersagt + Kompakt + Es läuft bereits eine Hintergrundsynchronisation + Akkuoptimierung deaktivieren + Kann dazu beitragen, dass die Hintergrundsynchronisierung nicht vom System beendet wird + OPML-Export erfolgreich + Zu Favoriten hinzufügen + Zugriff auf Benachrichtigungen erlauben + Benachrichtigungen nach Hintergrundsynchronisation anzeigen + Benachrichtigung über neue Artikel nach der Hintergrundsynchronisation + Die angegebene URL ist kein gültiger RSS-Feed + Mehr + Älteste Artikel zuerst anzeigen + Möchten Sie den Feed %1$s löschen? + Extern + Readrops ist abgestützt. + Kopiert! + Bitcoin (Adresse kopieren) + Litecoin (Adresse kopieren) + Bitte geben Sie die vollständige API-URL an + HTTP-Fehler %1$d, bitte überprüfe die Felder + HTTP-Fehler %1$d, Serverfehler + HTTP-Fehler %1$d + Aktualisierung von %1$s Konto + Aktivieren alle + Deaktiviere alle + Open-Source-Bibliotheken + Andere Accounts + Account umbenennen + Name + URL + %1$d ungelesen + Präferenzen + Artikelansicht + Timeline + Farbe aktualisieren + Bitte geben Sie die Root-URL des Dienstes an + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e87c4badc..f11fc7971 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,46 +1,29 @@ Esta es tu contraseña de la API de FreshRSS (Ajustes > Perfil) - Abrir menú - Cerrar menú - Añadir carpeta Ajustes - Acerca de El campo no puede estar vacío La entrada no es una URL válida Error de conexión - por %1$s 1 min %1$s min - Añadir feed + Añadir feed Añadir feed - Por leer Abrir URL Añadir carpeta - Carpeta Sin carpeta Cancelar - Cargar - Resultados - Carpeta de feed - Para leer más tarde Mostrar artículos leídos - Filtrar - Seleccionar todo URL de la cuenta Nombre de la cuenta Feed %1$s añadido exitosamente Fallo al analizar el feed %1$s Error desconocido para el feed %1$s Identificación - Ajustes de la cuenta Añadir cuenta Elige una cuenta Cuenta - Carpetas Sin feeds - Feeds y carpetas - Administrar feeds y carpetas Feeds ¿Borrar carpeta\? Borrar cuenta @@ -49,50 +32,32 @@ De más reciente a más antiguo De más antiguo a más reciente Inicio de sesión fallido. Por favor, revisa tus credenciales - Mal formato para la nueva carpeta - Ilimitado Local Borrar - Número máximo de elementos por feed - Webview - Error en el feed %1$s - Recargar colores de los feeds Abrir elementos en - Actualizar Compartir URL Importar/Exportar OPML - Procesando archivo OPML - Esta operación puede tardar un tiempo considerable, ya que se necesita consultar cada feed. - Inténtalo de nuevo Importación de OPML Exportación de OPML Claro Oscuro - Para descargar la imagen se necesita permiso de almacenamiento Sincronización automática Manual 30 min - Exportar feeds y carpetas 2 horas 3 horas 12 horas Todos los días %1$s nuevos artículos - Activar notificaciones Notificaciones Abrir Atrás - Mostrar leyenda Sincronizar - Activar las notificaciones de todos los feeds - Artículos no leídos <1 min Favoritos - Código fuente - Registro de cambios Validar %1$d sin leer - Ocurrió un error + Se produjo un error La carpeta no existe en el servidor Nueva cuenta Compartir Artículo @@ -100,46 +65,34 @@ Contraseña ¿Borrar cuenta\? Editar carpeta - No se encontró URL de feed Navegador web externo - Permisos - O - Aplicación publicada bajo la licencia GPLv3 + Aplicación publicada bajo la licencia GPLv3 Global - Ocurrió un error durante el procesamiento del archivo - La exportación de suscripciones necesita permiso de almacenamiento externo Opciones de Imagen Descargar imagen Compartir imagen Tema Bandera Readrops - Black White + BlackWhite 1 hora 6 horas - Para que se muestren las notificaciones es necesario que la sincronización automática esté activada. -\n¿Deseas abrir los ajustes\? + Para que se muestren, las notificaciones necesitan que la sincronización en segundo plano esté activada. +\n¿Deseas abrir los ajustes? Vista de navegador Sincronización de cuentas La sincronización automática está desactivada URL de feed Host desconocido - ¿Borrar feed\? + ¿Borrar el feed? Nombre del feed Error de red al acceder al feed %1$s Editar feed - Actualizando feed: %1$s Formato incorrecto para el feed %1$s El feed %1$s ha sido borrado El feed %1$s no existe en el servidor - Ningún feed encontrado Nuevo feed - %1$s feeds - %1$s feed - Sin elementos Obtener colores de feed - Colores de Feed - Aplicación publicada bajo la licencia GPLv3 Marcar como leído Tema del sistema Preferencias @@ -176,9 +129,8 @@ Bibliotecas de código abierto Otras cuentas Mostrar notificaciones después de la sincronización en segundo plano - Reciba notificaciones sobre nuevos artículos después de la sincronización en segundo plano + Recibir notificaciones de nuevos artículos tras la sincronización en segundo plano Sin artículos - La entrada no es una URL válida No se encontró ninguna fuente RSS La URL proporcionada no es una fuente RSS válida Más @@ -190,7 +142,6 @@ ¡Copiado! Si consideras que mi trabajo te es útil y deseas apoyarme, puedes hacer una donación. Error HTTP 403, acceso prohibido - El campo no puede estar vacío Actualizar color ¿Quieres eliminar la carpeta %1$s ? Todos los feeds vinculados también se eliminarán. ¿Estás seguro de que quieres marcar todos los artículos como leídos? @@ -199,7 +150,6 @@ Si ves esto, significa que la aplicación tuvo un error inesperado. Por favor si puedes, informa del error en Github. Litecoin (copiar dirección) Bitcoin (copiar dirección) - Añadir feed ¡Archivo descargado! Por favor, proporciona la URL completa de la API Por favor, proporcionaa la URL de la raíz del servicio @@ -220,4 +170,36 @@ URL Cambiar el nombre de la cuenta Nombre - \ No newline at end of file + Los feeds sin artículos nuevos desaparecerán del menú + Fecha + Identificador + Ascendente + Los feeds RSS siempre contienen artículos antiguos que es poco probable que veas en tu cronología si los ordenas por fecha. Ordenar por identificador de artículo te permite mostrar todos los artículos nuevos insertados, independientemente de la fecha en que los haya. + Descendente + Ordenar por + Con direccion + Advertencia + Google Reader es una API muy antigua a la que le falta documentación. Puede que no se aplique igual en todos los agregadores y por tanto puede que no funcione bien.\n\nSi encuentra algún problema de sincronización, por favor repórtelo al repositorio de GitHub de Readrops. + API + (%1$s seleccionado) + Introduzca una URL + El feed ya existe + Carpeta no válida + Ocurrió un error mientras se descargaba la imagen + Permitir notificaciones + Las notificaciones de la cuenta están desactivadas + Vista exterior + Sincronizar al iniciar + Vista previa + Seleccionar color + Restablecer + Acciones + Recargar color desde Favicon + Añadir %1$s feeds seleccionadas + Ocurrió un error al recargar el color Favicon + Escoja una carpeta + Vista local + Categoría por defecto + Usar color por defecto + Cargar color desde un nuevo Favicon + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5f899b81c..5d8c29632 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,62 +1,42 @@ - À lire - Articles non lus - Ouvrir le menu - Fermer le menu - Ajouter un flux - Ajouter un dossier + Ajouter un flux Paramètres - À propos Ajouter un flux Adresse du flux Valider Le champ ne peut pas être vide La valeur n\'est pas une adresse web valide - Aucune adresse de flux trouvée Erreur de connexion au site Site inconnu - par %1$s %1$s min < 1 min 1 min Partager l\'article Ouvrir le lien Ajouter un dossier - Dossier du flux Nom du flux Modifier le flux - Dossier Aucun dossier Retour Supprimer le flux ? - Charger - Mise à jour du flux : %1$s - Résultats Le flux %1$s a été ajouté Erreur d\'accès au flux %1$s Erreur de traitement pour le flux %1$s Mauvais format pour le flux %1$s Erreur inconnue pour le flux %1$s Articles - À lire plus tard Afficher les articles lus - Filtrer Marquer comme non lu Marquer comme lu - Tout sélectionner URL du compte Nom du compte Identifiant Mot de passe - Paramètres du compte Ajouter un compte Aucun flux Choisir un compte - Flux et dossiers Compte - Gérer les flux et dossiers - Dossiers Flux Modifier le dossier Supprimer le dossier ? @@ -66,7 +46,6 @@ Le flux %1$s n\'existe pas sur le serveur Une erreur s\'est produite Le dossier existe déjà - Mauvais format pour le nouveau dossier Le dossier n\'existe pas sur le serveur Identifiants Du plus récent au plus ancien @@ -74,47 +53,26 @@ La connexion a échoué. Veuillez vérifier vos identifiants Nouveau compte Nouveaux articles - Appli distribuée sous la licence GPLv3 - Nombre maximum d\'articles par flux - Illimité Local - %1$s flux - %1$s flux Supprimer - Aucun item - Aucun flux trouvé - Erreur pour le flux %1$s Récupération des couleurs des flux - Couleurs des flux Général - Recharger les couleurs des flux Ouvrir les articles avec - Vue web Navigateur externe - Actualiser Partager le lien Import/Export OPML - Traitement du fichier OPML - Cette opération peut prendre un certain temps car il faut interroger chaque flux. - Une erreur s\'est produite lors du traitement du fichier Import OPML Export OPML - L\'export des soubscriptions nécessite l\'accès au stockage - Réessayer - Permissions - Ou Options de l\'image Télécharger l\'image Partager l\'image Thème Schéma de couleurs Readrops - Black White + BlackWhite Clair Sombre - Export des flux et dossiers Nouveau flux - Le téléchargement de l\'image nécessite l\'accès au stockage Synchronisation automatique Manuel 30 min @@ -126,21 +84,17 @@ Chaque jour Synchronisation des comptes %1$s nouveaux articles - Activer les notifications Notifications - Activer toutes les notifications des flux La synchronisation automatique est désactivée - Les notifications nécessitent l\'activation de la synchronisation automatique pour fonctionner.\nVoulez-vous ouvrir les paramètres ? + Les notifications nécessitent l\'activation de la synchronisation automatique pour fonctionner. +\nVoulez-vous ouvrir les paramètres ? Ouvrir Retour - Afficher la légende Votre mot de passe d\'API (Configuration > Profil) Synchroniser Vue navigateur Favoris - Code source - Journal des modifications - App distribuée sous la licence GPLv3 + App distribuée sous la licence GPLv3 Thème du système Cacher les flux sans nouveaux items Marquer les items comme lus pendant le défilement @@ -198,8 +152,6 @@ Afficher les notifications après la synchronisation en arrière-plan Soyez prévenus des nouveaux articles après la synchronisation en arrière-plan Aucun article - La saisie n\'est pas une URL valide - Le champ ne peut pas être vide Aucun flux RSS trouvé L\'URL fournie n\'est pas un flux RSS valide Une erreur est survenue @@ -219,8 +171,68 @@ Bitcoin (copier l\'adresse) Litecoin (copier l\'adresse) "Si vous considérez que mon travail vous est utile et si vous souhaitez me soutenir, vous pouvez me faire une donation. " - Ajouter un flux Fichier téléchargé ! Merci de fournir l\'URL entière de l\'API Merci de fournir l\'URL racine du service - \ No newline at end of file + Les flux RSS contiennent toujours d\'anciens articles qu\'il est peu probable de voir apparaître dans votre timeline si vous la classez par date. Le classement par identifiant d\'article vous permet d\'afficher tous les nouveaux articles insérés, quelle que soit leur date. + Date + Identifiant + Ascendant + Descendant + Ordonner par + Avec comme direction + Choisir un dossier + (%1$s sélectionnés) + Ajouter %1$s flux sélectionnés + Entrer une URL + Le flux existe déjà + Dossier invalide + Flux invalide + Une erreur s\'est produite lors du téléchargement de l\'image + Activer les notifications + Les notifications du compte sont actuellement désactivées + Ouvrir le flux dans + Vue locale + Vue externe + Ne pas me redemander pour les autres flux + Catégorie par défaut + Synchroniser au démarrage + Couleur de flux + Prévisualisation + Choisir une couleur + Réinitialiser + Actions + Recharger la couleur à partir de la favicône + Charger la couleur depuis une nouvelle favicône + Une erreur est survenue lors du rechargement de la couleur de la favicône + Utiliser la couleur par défaut + Attention + J\'accepte + "Google Reader est une très vielle API et reste peu documentée. Il est probable qu'elle ne soit pas implémentée de la même manière dans chaque agrégateur et ainsi ne pas fonctionner correctement.\n\nSi vous rencontrez des problèmes de synchronisation, merci de les remonter sur le dépôt Github de Readrops. " + Fever est une vielle API, limitée et dépréciée. Elle ne supporte pas la gestion des flux et des dossiers. Elle est également mal documentée et risque de ne pas être implémentée de la même manière chez chaque agrégateur, ne permettant pas son bon fonctionnement.\n\nSi vous rencontrez des problèmes de synchronisation, merci de les remonter sur le dépôt Github de Readrops. + API + + + Personnaliser le texte du partage entre applications + Cette option vous permet de personnaliser le texte lorsque vous partagez un article avec d\'autres applications Android. La référence de la syntaxe possible <a href=https://pebbletemplates.io/wiki/guide/basic-usage/#syntax-reference>est disponible ici</a>. + Voir le texte généré + Editer le template + Excellente nouvelle tout le monde ! + Vidéaste incroyable + "Cela s\'est passé la semaine dernière sur terre.\n\nC'est la chose la plus impressionnante qui se soit produite cette semaine + Les variables disponibles dans le contexte du template sont les suivantes : %s. Vous pouvez utiliser tous les filtres de template <a href=https://pebbletemplates.io/wiki/>documentés ici</a>. En plus des filtres standard, vous pouvez utiliser : %s. + lorsqu\'appliqué à n\'importe quelle variable, placera un espace insécable avant %s + Remplir avec le template par défaut + Visualiser le résultat + Editer le template + lorsqu\'appliqué à une variable, en retire le contenu de la variable <tt>author</tt> + « %s » + %s : %s + Désactivé + Action de glisser à droite + Action de glisser à gauche + Voulez-vous supprimer le dossier %1$s ? Tous les flux associés seront déplacés vers le dossier par défaut. + Aucune connexion internet disponible + Le fichier est vide + Le contraste avec l\'arrière-plan est trop faible. La couleur par défaut sera utilisée à la place avec ce thème. + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 79ae7ec97..aea39f17c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1,63 +1,43 @@ - To read - Non read articles - Buka menu - Tutup menu - Tambah feed - Tambah folder + Tambah feed Pengaturan - Tentang Tambah feed Url feed Validasi Ruas tidak boleh kosong URL tidak valid - Url feed tidak ditemukan Galat koneksi Hos tidak diketahui - oleh %1$s %1$s mnt Kurang dari 1 menit 1 mnt Bagikan Artikel Buka url Tambah folder - Folder feed Nama feed Sunting feed - Folder Tidak ada folder Batal Hapus feed ? - Muat - Memperbarui feed : %1$s - Hasil Feed %1$s berhasil ditambahkan Jaringan terputus saat mengakses feed %1$s Terjadi kesalahan saat mengurai feed %1$s Kesalahan format untuk feed %1$s Kesalahan tidak diketahui untuk feed %1$s Artikel - Baca nanti Tampilkan artikel dibaca - Filter Tandai belum dibaca Tandai dibaca - Pilih semua Url akun Nama akun Masuk Sandi Ini adalah sandi API FreshRSS Anda (Konfigurasi > Profil) - Pengaturan akun Tambah akun Tidak ada feed Pilih akun - Feed dan folder Akun - Kelola feed dan folder - Folder Feed Sunting folder Hapus folder ? @@ -67,54 +47,33 @@ Feed %1$s tidak ada pada server Telah terjadi keslahan Folder sudah ada - Format tidak valid untuk folder baru Folder tidak ada pada server Kredensial Terbaru > terlama Terlama > terbaru Gagal masuk. Silakan periksa kredensial Anda Akun baru - Jumlah maksimum item per feed - Tidak terbatas Lokal - %1$s feed - %1$s feed Hapus - Aplikasi dirilis dengan lisensi GPLv3 - Tidak ada item - Feed tidak ditemukan - Galat feed %1$s + Aplikasi dirilis dengan lisensi GPLv3 Ambil warna feed - Warna Feed Global - Muat ulang warna feed Buka item di - Webview Peramban eksternal - Aktualisasikan Bagikan URL Impor/Ekspor OPML - Memproses berkas OPML - Proses ini bisa memakan waktu lama karena feed harus diproses satu persatu. - Telah terjadi kesalahan saat memproses berkas Impor OPML Ekspor OPML - Ekspor berkas OPML membutuhkan izin akses penyimpanan eksternal - Coba lagi - Perizinan - Atau Opsi Gambar Unduh gambar Bagikan gambar Tema Skema Warna Readrops - Black White + BlackWhite Terang Gelap - Ekspor feed dan folder Feed baru - Untuk mengunduh gambar, dibutuhkan izin akses penyimpanan Sinkronisasi otomatis Manual 30 mnt @@ -126,14 +85,12 @@ Sehari sekali Sinkronisasi akun %1$s artikel baru - Aktifkan notifikasi Notifikasi - Aktifkan semua notifikasi feed Sinkronisasi otomatis dinonaktifkan - Agar bisa ditampilkan, notifikasi membutuhkan sinkronisasi otomatis untuk diaktifkan.\nApakah Anda ingin membuka pengaturan ? + Agar bisa ditampilkan, notifikasi membutuhkan sinkronisasi otomatis untuk diaktifkan. +\nApakah Anda ingin membuka pengaturan ? Buka Kembali - Tampilkan takarir Sinkronkan Navigator view Favorit diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 897728684..8cbf7ec6a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,15 +4,12 @@ Preferiti Vista browser Sincronizza - Mostra didascalia Indietro Apri Per essere visualizzate, le notifiche hanno bisogno che la sincronizzazione automatica sia attivata. -\nVuoi aprire le impostazioni\? +\nVuoi aprire le impostazioni?
La sincronizzazione automatica è disabilitata - Abilita tutte le notifiche dei flussi Notifiche - Abilita le notifiche %1$s nuovi articoli Sincronizzazione degli account Ogni giorno @@ -24,52 +21,31 @@ 30 min Manuale Sincronizzazione automatica - Per scaricare l\'immagine, è necessaria l\'autorizzazione di archiviazione Nuovo flusso - Esportazione dei flussi e delle cartelle Scuro Chiaro Tema Combinazione di colori Readrops - Black White + BlackWhite Condividi l\'immagine Scarica l\'immagine Opzioni dell\'immagine - O - Autorizzazioni - Riprova - L\'esportazione delle sottoscrizioni necessita di un permesso di archiviazione esterna Esportazione OPML Importazione OPML - Si è verificato un errore durante l\'elaborazione del file - Questa operazione può richiedere un tempo significativo, poiché ogni flusso deve essere interrogato. - Elaborazione del file OPML Importazione/esportazione OPML Condividi l\'URL - Aggiorna - Webview Apri gli elementi con - Ricarica i colori dei flussi Generale - Colori dei flussi Recupera i colori dei flussi - Errore per il flusso %1$s - Nessun flusso trovato - Nessun elemento Elimina - %1$s flusso - %1$s flussi Locale - Illimitato - Numero massimo di articoli per flusso Nuovo account Accesso non riuscito. Per favore controlla le tue credenziali Dal più vecchio al più recente Dal più recente al più vecchio Credenziali La cartella non esiste sul server - Cattivo formato per la nuova cartella La cartella esiste già Si è verificato un errore Il flusso %1$s non esiste sul server @@ -79,62 +55,42 @@ Eliminare la cartella\? Modifica la cartella Flussi - Cartelle - Gestisci i flussi e le cartelle Account - Flussi e cartelle Scegli un account Nessuno flusso Aggiungi un account - Impostazioni dell\'account Questa è la tua password FreshRSS API (Configurazione > Profilo) Password Nome utente Nome dell\'account URL dell\'account - Seleziona tutto Segna come letto Segna come non letto - Filtra Mostra gli articoli letti - Da leggere più tardi Articoli Errore sconosciuto per il flusso %1$s Formato errato per il flusso %1$s Errore durante l\'analisi del flusso %1$s Errore di rete durante l\'accesso al flusso %1$s Il flusso %1$s è stato aggiunto con successo - Risultati - Aggiornamento del flusso: %1$s - Carica - Eliminare il flusso\? + Eliminare il flusso? Annulla Nessuna cartella - Cartella Modifica il flusso Nome del flusso - Cartella del flusso Aggiungi una cartella Apri l\'URL Condividi l\'articolo 1 min Meno di un minuto %1$s min - da %1$s Sito sconosciuto Errore di connessione - Nessun URL di flusso trovato L\'ingresso non è un URL valido Il campo non può essere vuoto Convalida URL del flusso Aggiungi un flusso - Informazioni Impostazioni - Aggiungi una cartella - Aggiungi un flusso - Chiudi il menù - Apri il menù - Articoli non letti - Da leggere + Aggiungi un flusso \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 000000000..2a5614246 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,163 @@ + + + フィードを追加 + この欄の入力は必須です + 入力された URL は無効です + 接続エラー + フィード名 + フィードを編集 + フィードを削除しますか? + %1$s 分 + 1 分未満 + 1 分 + 記事を共有 + URL を開く + フォルダーを追加 + フォルダーなし + キャンセル + 設定 + 確認 + フィードを追加 + フィード URL + タイムライン + フィード %1$s を追加しました + フィード %1$s へのアクセス中にネットワークエラーが発生しました + フィード + アカウント + 不明なホストです + 記事 + 既読の記事を表示する + 未読にする + 既読にする + アカウント URL + アカウント名 + ログイン + パスワード + FreshRSS の API パスワード (設定 > プロフィール) を入力してください + アカウントを追加 + フィードがありません + アカウントを選択 + フォルダーを編集 + フォルダーを削除しますか? + アカウント削除 + 本当にアカウントを削除しますか? + フィード %1$s を削除しました + フィード %1$s の解析中にエラーが発生しました + フィード %1$s のフォーマットが間違っています + フィード %1$s に未知のエラーが発生しました + フィード %1$s はサーバーに存在しません + エラーが発生しました + フォルダーが既に存在します + フォルダーがサーバーに存在しません + 認証情報 + 新しい順 + 古い順 + ログインに失敗しました。認証情報を確認してください + 新しいアカウント + ローカル + 削除 + アプリは GPLv3 ライセンスの下で公開されています + 外部のウェブブラウザー + 次でアイテムを開く + URL を共有 + OPML インポート + OPML エクスポート + 画像オプション + 画像を共有 + テーマ + ライト + ダーク + 自動同期 + 手動 + 30 分 + 1 時間 + 2 時間 + 3 時間 + 6 時間 + 12 時間 + 毎日 + アカウント同期 + 新着記事 %1$s 件 + 自動同期は無効です + 開く + 戻る + 同期 + お気に入り + システム標準 + OPML インポート/エクスポート + 画像をダウンロード + 新しいフィード + 通知 + アカウント %1$s 件を更新中 + すべて無効にする + 寄付 + オープンソースライブラリー + 全般 + アプリ内表示 + 新着アイテムがないフィードを隠す + スクロールでアイテムを既読にする + 新着記事 + フィルター + An error occurred + 詳細 + 同期エラー + + 以下のフィードでエラーが発生しました: + + 到達できないフィードです。HTTP エラー %1$s + ネットワークエラー: %1$s + フィード処理エラー + 到達できない URL + すべて有効にする + 更新 + アカウント名を変更 + 名前 + URL + 未読 %1$d 件 + 記事表示 + 未読アイテムがないフィードはドロワーで非表示になります + + + + アイテムの大きさ + バックグラウンド同期は既に実行中です + バッテリー最適化を無効にする + Litecoin (アドレスをコピー) + API がフィード URL の編集を許可していません + システム通知が無効になっています。ここをクリックして通知を有効にしてください + バックグラウンド同期がシステムに停止されなくなります + 通知へのアクセスを許可 + ファイルを開けませんでした + HTTP エラー 400、サーバーの URL を確認してください + 詳細 + HTTP エラー 401、認証情報を確認してください + HTTP エラー 403、アクセスが禁止されています + HTTP エラー 403、URL が見つかりませんでした + HTTP エラー %1$d、フィールドを確認してください + HTTP エラー %1$d、サーバーエラー + HTTP エラー %1$d + 他のアカウント + 設定 + このアプリのバッテリー最適化は無効になっています + OPML エクスポートが完了しました + お気に入りに追加 + Bitcoin (アドレスをコピー) + クリップボードにエラーをコピー + コピーしました! + Github でエラーを報告 + 記事がありません + バックグラウンド同期後に通知を表示する + バックグラウンド同期後に新着記事を通知します + 入力した URL は有効な RSS フィードを提供していません + 本当にフォルダー %1$s を削除しますか?リンクされているすべてのフィードは既定のフォルダーに移動されます。 + 通知を表示するにはバックグラウンド同期が有効にする必要があります。\n設定を開きますか? + 保存 + 本当にフィード %1$s を削除しますか? + 本当にフォルダー %1$s を削除しますか?リンクされているすべてのフィードも削除されます。 + RSS フィードが見つかりません + すべての記事を既読にする + 本当にすべてのアイテムを既読にしますか? + 日付 + 昇順 + 降順 + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5f83720d0..f21a95ac7 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -8,13 +8,10 @@ Drakt Fargevalg Readrops - Black White + BlackWhite Del bilde Last ned bilde Bildevalg - Eller - Tilganger - Prøv igjen OPML-eksport OPML-import Slett @@ -25,50 +22,35 @@ Slett mappe\? Rediger mappe Informasjonskanaler - Mapper Konto Kontonettadresse - Kontoinnstillinger Passord Kontonavn - Velg alle Marker som lest Marker som ulest - Filter Vis leste artikler Artikler - Last inn - Slett informasjonskanal\? + Slett informasjonskanal? Avbryt Ingen mappe - Mappe Rediger informasjonskanal Informasjonskanalsnavn - Informasjonskanalsmappe Legg til mappe Åpne nettadresse Informasjonskanalsnettadresse - Fant ingen informasjonskanalsnettadresse Feltet kan ikke stå tomt Bekreft Legg til informasjonskanal - Om Innstillinger - Legg til mappe - Legg til informasjonskanal - Lukk meny - Åpne meny + Legg til informasjonskanal Del nettadresse Identitetsdetaljer - Resultater Del artikkel Ett minutt Mindre enn ett minutt %1$s min - av %1$s Ukjent vert Tilkoblingsfeil - Ikke-leste artikler Dette er ikke en gyldig nettadresse Innlogging Dette er ditt FreshRSS-API-passord (Oppsett → Profil) @@ -77,67 +59,39 @@ Nyeste til eldst Eldste til nyeste Kunne ikke logge inn. Sjekk brukernavn og passord. - Ingen elementer - Fant ingen informasjonskanaler - Feil for informasjonskanalen %1$s Hent informasjonskanalfarger - Informasjonskanalfarger Hele programmet - Hent informasjonskanalfarger Åpne elementer i - Nettvisning - Lagringstilgang trengs for å laste ned bildet Automatisk synkronisering må skrus på for visning. -\nØnsker du å åpne innstillingene\? - Fremtidig leseliste +\nØnsker du å åpne innstillingene?
Informasjonskanalen %1$s finnes ikke på tjeneren En feil inntraff Informasjonskanalen %1$s ble oppdatert Nettverksfeil ved tilgang til informasjonskanalen %1$s - Informasjonskanaler og mapper - Oppdaterer informasjonskanal: %1$s%1$s Velg en konto - Denne operasjonen kan ta sin tid siden hver informasjonskanal må spørres. - Leseliste Klarte ikke å fortolke informasjonskanalen %1$s Informasjonskanalen %1$s har blitt slettet Feilaktig format for informasjonskanalen %1$s Ukjent feil for informasjonskanalen %1$s - Håndter informasjonskanaler og mapper - Feilaktig format for den nye mappen Mappen finnes ikke på tjeneren - Behandler OPML-fil … - Kunne ikke behandle filen 30 min 1 time 12 timer Hver dag Kontosynkronisering - Skru på merknader Merknader - Eksporter informasjonskanaler og mapper Automatisk synkronisering 2 timer 3 timer 6 timer %1$s nye artikler - Skru på alle informasjonskanal-merknader - Maksimalt antall elementer per informasjonskanal Lokal Automatisk synkronisering avslått Tilbake Synkroniser Åpne - Vis bildeundertekst Navigasjonsvisning - Kildekode - Endringslogg - Ubegrenset - %1$s informasjonskanaler - %1$s informasjonskanal - Lisensiert GPLv3+ + Lisensiert GPLv3+ Ekstern nettleser - Aktualiser OPML-import/-eksport - Abonnementseksport trenger tilgang til eksternlagring \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fbf38fcef..d39e2a14f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,42 +1,173 @@ Feed toevoegen - Menu openen - Over Verifiëren Het veld mag niet blanco zijn De url is ongeldig - Er is geen feed-url aangetroffen Er is een verbindingsfout opgetreden - van %1$s %1$s min. <1 min. Url openen - Feedmap Feednaam Feed bewerken - Resultaten ‘%1$s’ is toegevoegd - Bezig met bijwerken van ‘%1$s’… - Nog te lezen De app is uitgebracht onder de GPLv3-licentie - Ongelezen artikelen - Menu sluiten - Feed toevoegen Instellingen Feed-url Onbekende host - Map toevoegen 1 min. Map toevoegen - Map Annuleren Artikel delen Feed verwijderen? - Laden Geen map + Fout tijdens verwerken van ‘%1$s’ + Verbindingsfout tijdens ophalen van ‘%1$s’ + Onjuiste bestandsindeling van ‘%1$s’ + Onbekende foutmelding van ‘%1$s’ + Artikelen + Gelezen artikelen tonen + Markeren als ongelezen + Markeren als gelezen + Geen feed + Kies een account + Account + Feeds + Map verwijderen? + Deze map bestaat al + Deze map is niet aangetroffen op de server + Lokaal + Verwijderen + Feedkleuren ophalen + Globaal + Items openen in + Externe webbrowser + Url delen + Opml-bestand im-/exporteren Thema + Licht + Donker Kleurenschema Readrops - Black White - \ No newline at end of file + BlackWhite + Opml-bestand importeren + Opml-bestand exporteren + Afbeeldingsopties + Afbeelding downloaden + Afbeelding delen + Nieuwe feed + Automatisch synchroniseren + Handmatig + 30 min. + 1 uur + Accountsynchronisatie + %1$s nieuwe artikelen + Meldingen + Automatisch synchroniseren is uitgeschakeld + Meldingen kunnen alleen worden getoond als achtergrondsynchronisatie is ingeschakeld. +\nWil je de instellingen openen? + Openen + Terug + Bijwerken + Itemgrootte + Tijdlijn + Feeds zonder ongelezen items zijn alleen bereikbaar via het navigatiemenu + Normaal + Groot + Klein + Accuoptimalisatie uitschakelen + Geen artikel + Systeemmeldingen zijn uitgeschakeld - druk om in te schakelen + Meldingen tonen na achtergrondsynchronisatie + Meldingen tonen van nieuwe artikelen na achtergrondsynchronisatie + Weet je zeker dat je ‘%1$s’ wilt verwijderen? + Weet je zeker dat je ‘%1$s’ wilt verwijderen? Alle bijbehorende feeds worden eveneens verwijderd. + Geen rss-feed aangetroffen + De opgegeven url is geen geldige rss-feed + Meer + Oudste artikelen eerst tonen + Alle artikelen markeren als gelezen + Weet je zeker dat je alle items als gelezen wilt markeren? + Readrops is gecrasht. + Bitcoin (adres kopiëren) + Litecoin (adres kopiëren) + Feed toevoegen + Account-url + Map bewerken + Account verwijderen + Account verwijderen? + ‘%1$s’ is verwijderd + Er is een fout opgetreden + Inloggegevens + Nieuw → oud + Het inloggen is mislukt - controleer je inloggegevens + Nieuw account + Synchroniseren + Navigatie + Favorieten + Systeemthema + Feeds zonder nieuwe items verbergen + Items na scrollen markeren als gelezen + Nieuwe artikelen + Filters + Http-fout %1$d + De api heeft geen ondersteuning voor het aanpassen van feedurl\'s + Doneren + Andere accounts + Accountnaam wijzigen + Naam + Url + Er wordt al op de achtergrond gesynchroniseerd + Dit kan helpen als achtergrondsynchronisatie wordt afgebroken door het systeem + Het opml-bestand is geëxporteerd + Toevoegen aan favorieten + Toegang geven tot meldingen + Kleur bijwerken + Extern + Als je dit scherm ziet, dan is er een onverwachte fout opgetreden. Meld dit voorval op GitHub. + Melden op GitHub + Foutmelding kopiëren naar klembord + Gekopieerd! + Als je mijn werk waardeert en me wilt ondersteunen, overweeg dan een donatie te doen. + Het bestand is gedownload! + Voer de volledige api-url in + Voer de volledige hoofd-url in + Accountnaam + Wachtwoord + Dit is je FreshRSS-api-wachtwoord (Instellingen → Profiel) + Account toevoegen + Gebruikersnaam + ‘%1$s’ is niet aangetroffen op de server + 6 uur + Synchronisatiefouten + Oud → nieuw + %1$d ongelezen + 2 uur + 3 uur + Instellingen + Artikelweergave + 12 uur + Iedere dag + Accuoptimalisatie is al uitgeschakeld + Details + Http-fout 400: controleer de server-url + Http-fout %1$d: serverfout + An error occurred + + De volgende feed gaf een foutmelding: + De volgende feeds gaven een foutmelding: + + De feed is onbereikbaar - http-fout %1$s + Geen internetverbinding: %1$s + De feed kan niet worden verwerkt + De url is onbereikbaar + Het bestand kan niet worden geopend + Http-fout 403: toegang verboden + Http-fout 404: de url is onjuist + Http-fout 401: controleer je inloggegevens + Http-fout %1$d: controleer de velden + Bezig met bijwerken van %1$s… + Alles inschakelen + Alles uitschakelen + Opensourcebibliotheken + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 43181f8df..e127dc609 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,129 +1,85 @@ - Sobre - %1$s mins - Menos de um minuto + %1$s min + <1 min 1 min - Checando o Feed : %1$s - Resultados O Feed %1$s adicionado com sucesso Falha de rede ao acessar o Feed %1$s Falha ao processar o Feed %1$s Formato incorreto do Feed %1$s Erro desconhecido do Feed %1$s Artigos - Para ler mais tarde Mostrar artigos lidos - Filtro - Marcar como não lido - Selecionar tudo + %1$d não lido(s) URL da conta Nome da conta Login Senha Esta é sua senha do FreshRSS API (Configuração > Perfil) - Configurações da conta Adicionar uma conta Nenhum Feed Escolha uma conta - Feeds e pastas Conta - Gerenciar Feeds e pastas Credenciais Mais recentes primeiro Mais antigos primeiro Nova conta - Ilimitado Local - %1$s Feeds - %1$s Feed Eliminar - App registrado sob a licença GPLv3 - Nenhum artigo - Nenhum Feed encontrado - Erro do Feed %1$s + App registrado sob a licença GPLv3 Obter cores de Feeds - Cores de Feeds Global - Recarregar cores de Feeds Compartilhar URL - Esta operação pode levar algum tempo, já que cada Feed precisa ser consultado. - Ocorreu um erro durante o processamento do arquivo - Para exportar as assinaturas é necessário conceder a permissão de armazenamento - Tente novamente - Permissões - Ou Opções de imagem Baixar a imagem Compartilhar a imagem Tema Esquema de cores Readrops - Black White + BlackWhite Claro Escuro - Exportar Feeds e pastas Novo Feed - Para baixar a imagem é necessária a permissão de armazenamento Sincronização automática - Não lido Compartilhe o artigo Eliminar a pasta \? O Feed %1$s não existe no servidor Sincronização de contas %1$s novos artigos - Ativar notificações Notificações - Ativar notificações para todos os Feeds A sincronização automática é desativada A fim de serem mostradas, as notificações precisam de sincronização automática ativada. -\nVocê quer abrir menu de configurações\? +\nVocê quer abrir menu de configurações?
Abrir Voltar - Mostrar legenda Sincronizar Vista de navegador Aprovar - por %1$s Url do Feed - Artigos não lidos - Abrir menu - Fechar menu - Adicionar pasta - Adicionar Feed + Adicionar Feed Configurações Adicionar Feed O campo não pode estar em branco A entrada não é uma URL válida - Nenhuma URL do Feed encontrada Erro de conexão Host desconhecido Adicionar pasta Nenhuma pasta Cancelar - Pasta Ocorreu um erro - Pasta do Feed Nome do Feed Editar o Feed - Eliminar o Feed\? + Eliminar o Feed? A pasta já existe Abrir URL - Carregar Editar pasta Abrir artigos via - Pastas Feeds Eliminar conta Eliminar a conta\? O Feed %1$s foi eliminado - Formato incorreto para a nova pasta A pasta não existe no servidor Não foi possível fazer login. Por favor, verifique suas credenciais - Número máximo de artigos por Feed - Atualize - Processando o arquivo OPML - Webview Navegador externo Importar/Exportar OPML Importar do OPML @@ -132,11 +88,146 @@ Diariamente 2 horas Manual - 30 mins + 30 min 1 hora 3 horas 12 horas Favoritos - Código fonte - Histórico de modificações - \ No newline at end of file + Marcar como lido + Tema do sistema + Detalhes + Erros de sincronização + Falha na rede: %1$s + Incapaz de abrir o arquivo + Erro HTTP %1$d + Conta de atualização %1$s + Faça uma doação + Nome + URL + Preferências + Linha do tempo + Ocultar feeds sem novos itens + Renomear conta + Marcar como não lido + Desabilitar tudo + Erro HTTP 401, verifique suas credenciais + Salvar + Filtros + + Um erro ocorreu para o seguinte feed: + Alguns erros ocorreram para os seguintes feeds: + Alguns erros ocorreram para os seguintes feeds: + + Atualização + Marcar itens lidos ao rolar + An error occurred + Novos artigos + Feed não acessível, erro HTTP %1$s + Processando erro do feed + URL não acessível + Erro HTTP 400, verifique a URL do servidor + Erro HTTP 403, acesso proibido + Erro HTTP 404, URL não encontrada + Erro HTTP %1$d, verifique seus campos + Erro HTTP %1$d, erro de servidor + A API não suporta modificação do URL do Feed + Permitir tudo + Bibliotecas de código aberto + Outras contas + Visão do Artigo + Aviso + Eu compreendo + API + Não me pergunte novamente para os próximos feeds + Categoria padrão + O arquivo está vazio + Escolha uma pasta + (%1$s selecionado) + Adicionar %1$s feeds selecionados + Insira uma URL + O Feed já existe + Pasta inválida + Feed inválido + Um erro ocorreu ao baixar a imagem + Nenhuma conexão de rede disponível + Normal + Conceder acesso às notificações + As notificações do sistema estão atualmente desativadas. Clique aqui para ativá-las + Mostrar notificações após a sincronização em segundo plano + Sem artigos + Você quer excluir o feed %1$s? + Você realmente quer marcar todos os itens como lido? + Se você acha que o meu trabalho é útil e gostaria de me apoiar, você pode considerar fazer uma doação. + Arquivo baixado! + Descendente + Ascendente + Vista local + Externo + %s + Fever é uma API muito antiga, limitada e depreciada. Não suporta o gerenciamento de feeds e pastas. Também é mal documentado, pode não ser implementado para cada agregador da mesma forma e, portanto, pode não funcionar bem.\n\nSe você encontrar qualquer problema de sincronização, por favor, informe-os ao repositório do Readrops no Github. + quando aplicado a qualquer variável, este filtro irá remover dele o conteúdo da variável author + %s: %s + Google Reader é uma API muito antiga e falta de documentação. Pode não ser implementado para cada agregador da mesma forma e, portanto, pode não funcionar bem.\n\nSe você encontrar qualquer problema de sincronização, por favor, informe-os ao repositório do Readrops no Github. + Feeds RSS sempre contêm artigos antigos e é improvável que você verá em sua linha do tempo se você ordená-lo por data. Ordenando pelo identificador do artigo permitirá que você mostre todos os novos artigos inseridos não importando a data que eles têm. + Identificação + Vista externa + Você quer excluir a pasta %1$s? Todos os feeds vinculados serão colocados na pasta padrão. + Litecoin (copiar endereço) + Redefinir + Ações + Carregar cor de um novo favicon + Um erro ocorreu ao recarregar a cor do favicon + Recarregar cor do Favicon + Feeds sem itens não lidos serão escondidos no menu + Compacto + Largo + Tamanho do item + Uma sincronização em segundo plano já está em execução + Desativar a otimização da bateria + Pode ajudar com a sincronização em segundo plano não sendo finalizada pelo sistema + Otimização de bateria já desativada para este aplicativo + Sucesso na exportação OPML + Adicionar aos favoritos + Ser notificado de novos artigos após a sincronização em segundo plano + Nenhum feed RSS encontrado + A URL fornecida não é um feed RSS válido + Mais + Mostrar artigos mais antigos primeiro + Data + Ordenado por + Com direção + Habilitar notificações + Notificações de conta estão desabilitadas + Abrir Feed em + Sincronizar ao abrir + Cor do Feed + Visualização + Selecione a cor + Use a cor padrão + Você quer excluir a pasta %1$s? Todos os feeds vinculados também serão excluídos. + Atualizar a cor + Marcar todos os artigos como lido + Readrops falhou. + Eu vejo isso, significa que o aplicativo correu para um erro inesperado. Por favor, se puder, informe o erro no Github. + Relatar o erro no Github + Copiar erro para a área de transferência + Copiado! + Bitcoin (copiar endereço) + Por favor, forneça a URL completa da API + Por favor, forneça o URL raiz do serviço + Personalize o texto ao compartilhar em outros aplicativos + Esta opção permite personalizar o texto ao compartilhar um artigo em outros aplicativos Android. Verificar <a href=https://pebbletemplates.io/wiki/guide/basic-usage/#syntax-reference> a sintaxe disponível aqui</a>. + Visualizar texto compartilhado + Editar modelo de texto compartilhado + Boas notícias, pessoal! + Criador de conteúdo incrível + Isto aconteceu na semana passada no planeta Terra.\n\nEsta é a coisa mais incrível que aconteceu esta semana. + As variáveis que estão disponíveis no contexto são: %s. Você pode usar todos os filtros de modelo <a href=https://pebbletemplates.io/wiki/>documentados aqui</a> além de: %s. + quando aplicado a qualquer variável, colocará um espaço inquebrável antes %s + Preencha com o modelo padrão + Ver o resultado + Editar modelo + Desabilitado + Ação ao deslizar para direita + Ação ao deslizar para esquerda + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 000000000..2580ee2a4 --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,206 @@ + + + ஊட்டத்தைச் சேர்க்கவும் + அமைப்புகள் + ஊட்டத்தைச் சேர்க்கவும் + உள்ளீடு சரியான முகவரி அல்ல + இணைப்பு பிழை + திறந்த முகவரி + ஊட்டத்தை நீக்கவா? + %1$s ஐ வெற்றிகரமாகச் சேர்க்கவும் + ஊட்டத்திற்கான தவறான வடிவம் %1$s + ஊட்டத்திற்கு தெரியாத பிழை %1$s + கட்டுரைகள் + வாசிப்பு கட்டுரைகளைக் காட்டு + இது உங்கள் ஃப்ரெச்ஆர்எச்எச் பநிஇ கடவுச்சொல் (உள்ளமைவு> சுயவிவரம்) + கணக்கைச் சேர்க்கவும் + ஊட்டம் இல்லை + கோப்புறையைத் திருத்து + கோப்புறையை நீக்கவா? + கணக்கை நீக்கு + தீவனம் %1$s நீக்கப்பட்டது + ஊட்டம் %1$s சேவையகத்தில் இல்லை + பிழை ஏற்பட்டது + கோப்புறை ஏற்கனவே உள்ளது + கோப்புறை சேவையகத்தில் இல்லை + நற்சான்றிதழ்கள் + உள்நுழைவு தோல்வியடைந்தது. உங்கள் சான்றுகளை சரிபார்க்கவும் + புதிய கணக்கு + உள்ளக + நீக்கு + GPLV3 உரிமத்தின் கீழ் வெளியிடப்பட்ட பயன்பாடு + உலகளாவிய + வெளிப்புற வலை உலாவி + முகவரி ஐப் பகிரவும் + OPML இறக்குமதி + OPML ஏற்றுமதி + பட விருப்பங்கள் + படத்தைப் பதிவிறக்கவும் + படத்தைப் பகிரவும் + கருப்பொருள் + ஒளி + இருண்ட + புதிய ஊட்டம் + தானியங்கி ஒத்திசைவு + கையேடு + 30 மணித்துளி + 1 மணி நேரம் + 6 மணி நேரம் + 12 மணி நேரம் + தினமும் + %1$s புதிய கட்டுரைகள் + அறிவிப்புகள் + தானியங்கி ஒத்திசைவு முடக்கப்பட்டுள்ளது + திற + பின் + ஒத்திசைக்கவும் + கணினி கருப்பொருள் + An error occurred + விவரங்கள் + ஒத்திசைவு பிழைகள் + அணுக முடியாத ஊட்டம், http பிழை %1$s + HTTP பிழை 400, உங்கள் சேவையக முகவரி ஐ சரிபார்க்கவும் + HTTP பிழை 403, அணுகல் தடைசெய்யப்பட்டுள்ளது + HTTP பிழை 404, முகவரி கிடைக்கவில்லை + HTTP பிழை %1$d, தயவுசெய்து உங்கள் புலங்களைச் சரிபார்க்கவும் + ஊட்ட முகவரி மாற்றத்தை பநிஇ ஆதரிக்காது + %1$s கணக்கைப் புதுப்பித்தல் + அனைத்தையும் முடக்கு + திறந்த மூல நூலகங்கள் + கணக்கு மறுபெயரிடுங்கள் + பெயர் + முகவரி + காலவரிசை + கச்சிதமான + வழக்கமான + பெரிய + பேட்டரி தேர்வுமுறை முடக்கு + கணினியால் கொல்லப்படாத பின்னணி ஒத்திசைவு உதவ முடியும் + பிடித்தவைகளில் சேர்க்கவும் + ஆர்.எச்.எச் ஊட்டம் எதுவும் கிடைக்கவில்லை + வழங்கப்பட்ட முகவரி சரியான RSS ஊட்டம் அல்ல + நகலெடுக்கப்பட்டது! + பிட்காயின் (நகல் முகவரி) + லிட்காயின் (நகல் முகவரி) + பதிவிறக்கம் செய்யப்பட்ட கோப்பு! + முழு பநிஇ முகவரி ஐ வழங்கவும் + பணி ரூட் முகவரி ஐ வழங்கவும் + திகதி + அடையாளங்காட்டி + ஏறுதல் + ஒரு கோப்புறையைத் தேர்வுசெய்க + (%1$s தேர்ந்தெடுக்கப்பட்டது) + %1$s தேர்ந்தெடுக்கப்பட்ட ஊட்டங்களைச் சேர்க்கவும் + தவறான ஊட்டம் + படத்தைப் பதிவிறக்கும் போது பிழை ஏற்பட்டது + அறிவிப்புகளை இயக்கவும் + கணக்கு அறிவிப்புகள் தற்போது முடக்கப்பட்டுள்ளன + துவக்கத்தில் ஒத்திசைக்கவும் + உணவு நிறம் + முன்னோட்டம் + வண்ணத்தைத் தேர்ந்தெடுக்கவும் + மீட்டமை + செயல்கள் + ஃபாவிகானிலிருந்து வண்ணத்தை மீண்டும் ஏற்றவும் + புதிய ஃபாவிகானிலிருந்து வண்ணத்தை ஏற்றவும் + ஃபாவிகான் வண்ணத்தை மீண்டும் ஏற்றும்போது பிழை ஏற்பட்டது + இயல்புநிலை நிறத்தைப் பயன்படுத்தவும் + பநிஇ + பொதுவிதிகள் இல்லை + முகவரி க்கு உணவளிக்கவும் + சரிபார்க்கவும் + புலம் காலியாக இருக்க முடியாது + ஊட்டத்தைத் திருத்து + தெரியாத புரவலன் + %1$s மணித்துளி + <1 மணித்துளி + 1 மணித்துளி + கட்டுரை பகிர்ந்து கொள்ளுங்கள் + கோப்புறையைச் சேர் + உணவுப் பெயர் + கோப்புறை இல்லை + ரத்துசெய் + ஊட்டத்தை அணுகும்போது பிணையம் தோல்வி %1$s + ஊட்டத்தை பாகுபடுத்தும்போது தோல்வி %1$s + படிக்காத எனக் குறிக்கவும் + படித்தபடி குறி + கணக்கு முகவரி + கணக்கு பெயர் + புகுபதிவு + கடவுச்சொல் + ஒரு கணக்கைத் தேர்வுசெய்க + கணக்கு + ஊட்டங்கள் + கணக்கை நீக்கவா? + புதியது முதல் பழமையானது + பழமையானது முதல் புதியது + ஊட்டங்களின் வண்ணங்களைப் பெறுங்கள் + திறந்த உருப்படிகளைத் திறக்கவும் + OPML இறக்குமதி/ஏற்றுமதி + 2 மணி நேரம் + 3 மணி நேரம் + கணக்குகள் ஒத்திசைவு + நேவிகேட்டர் பார்வை + காண்பிக்கப்படுவதற்கு, அறிவிப்புகளுக்கு பின்னணி ஒத்திசைவு செயல்படுத்தப்பட வேண்டும்.\n அமைப்புகளைத் திறக்க விரும்புகிறீர்களா? + பிடித்தவை + புதிய உருப்படிகள் இல்லாமல் ஊட்டங்களை மறைக்கவும் + சுருளில் படித்த உருப்படிகளைக் குறிக்கவும் + புதிய கட்டுரைகள் + வடிப்பான்கள் + + பின்வரும் ஊட்டத்திற்கு பிழை ஏற்பட்டது: + பின்வரும் ஊட்டங்களுக்கு சில பிழைகள் ஏற்பட்டன: + + HTTP பிழை 401, தயவுசெய்து உங்கள் சான்றுகளை சரிபார்க்கவும் + பிணையம் தோல்வி: %1$s + தீவன பிழை செயலாக்க + அணுக முடியாத முகவரி + கோப்பைத் திறக்க முடியவில்லை + Http பிழை %1$d, சேவையக பிழை + Http பிழை %1$d + அனைத்தையும் இயக்கு + நன்கொடை செய்யுங்கள் + பிற கணக்குகள் + புதுப்பிப்பு + %1$d படிக்கவில்லை + விருப்பத்தேர்வுகள் + கட்டுரை பார்வை + இடது படிக்காத உருப்படிகள் இல்லாத ஊட்டங்கள் டிராயரில் மறைக்கப்படும் + உருப்படி அளவு + இந்த பயன்பாட்டிற்கு பேட்டரி தேர்வுமுறை ஏற்கனவே முடக்கப்பட்டுள்ளது + பின்னணி ஒத்திசைவு ஏற்கனவே இயங்குகிறது + OPML ஏற்றுமதி செய் + அறிவிப்புகளுக்கு அணுகல் வழங்கவும் + கணினி அறிவிப்புகள் தற்போது முடக்கப்பட்டுள்ளன. அவற்றை இயக்க இங்கே சொடுக்கு செய்க + பின்னணி ஒத்திசைவுக்குப் பிறகு அறிவிப்புகளைக் காட்டு + பின்னணி ஒத்திசைவுக்குப் பிறகு புதிய கட்டுரைகள் குறித்து அறிவிக்கப்படும் + மேலும் + முதலில் பழமையான கட்டுரைகளை காட்டு + ஊட்டத்தை %1$s ஐ நீக்க விரும்புகிறீர்களா? + கோப்புறையை %1$s ஐ நீக்க விரும்புகிறீர்களா? இணைக்கப்பட்ட அனைத்து ஊட்டங்களும் நீக்கப்படும். + வண்ணத்தைப் புதுப்பிக்கவும் + வெளிப்புறம் + எல்லா கட்டுரைகளையும் வாசித்தபடி குறிக்கவும் + எல்லா உருப்படிகளையும் படிக்க நீங்கள் உண்மையில் குறிக்க விரும்புகிறீர்களா? + ரீட் ராப்ச் செயலிழந்தது. + இதை நீங்கள் காண்கிறீர்கள், இதன் பொருள் பயன்பாடு எதிர்பாராத பிழையில் இயங்கியது. தயவுசெய்து உங்களால் முடிந்தால், கிட்அப்பில் பிழையைப் புகாரளிக்கவும். + கிட்அப்பில் பிழையைப் புகாரளிக்கவும் + பிழையை இடைநிலைப்பலகைக்கு நகலெடுக்கவும் + நான் எனது வேலையை பயனுள்ளதாகக் கருதுகிறேன், நீங்கள் என்னை ஆதரிக்க விரும்புகிறீர்கள், என்னை நன்கொடையாக மாற்றுவதை நீங்கள் கருத்தில் கொள்ளலாம். + ஆர்எச்எச் ஊட்டங்கள் எப்போதுமே பழைய கட்டுரைகளைக் கொண்டிருக்கின்றன, நீங்கள் தேதியின்படி ஆர்டர் செய்தால் உங்கள் காலவரிசையில் நீங்கள் காண்பது சாத்தியமில்லை. கட்டுரை அடையாளங்காட்டி மூலம் ஆர்டர் செய்வது, புதிய செருகப்பட்ட கட்டுரைகள் அனைத்தையும் அவர்கள் வைத்திருந்த தேதியைக் காட்ட உங்களை அனுமதிக்கிறது. + இறங்கு + வழங்கியவர் + திசையுடன் + ஒரு முகவரி ஐ உள்ளிடவும் + ஊட்டம் ஏற்கனவே உள்ளது + தவறான கோப்புறை + திறந்த தீவனம் + உள்ளக பார்வை + வெளிப்புற பார்வை + அடுத்த ஊட்டங்களுக்கு மீண்டும் என்னிடம் கேட்க வேண்டாம் + இயல்புநிலை வகை + எச்சரிக்கை + எனக்கு புரிகிறது + காய்ச்சல் மிகவும் பழமையான, வரையறுக்கப்பட்ட மற்றும் மதிப்பிழந்த பநிஇ. இது தீவனம் மற்றும் கோப்புறை நிர்வாகத்தை ஆதரிக்காது. இது மோசமாக ஆவணப்படுத்தப்பட்டுள்ளது, ஒவ்வொரு திரட்டியிலும் அதே வழியில் செயல்படுத்தப்படாமல் போகலாம், இதனால் நன்றாக வேலை செய்யாது.\n\n ஏதேனும் ஒத்திசைவு சிக்கலை நீங்கள் சந்தித்தால், தயவுசெய்து அவற்றை அறிவிலிமையம் களஞ்சியத்தை ரீட் ராப் செய்ய புகாரளிக்கவும். + கூகிள் ரீடர் மிகவும் பழைய பநிஇ மற்றும் ஆவணங்கள் இல்லாதது. இது ஒவ்வொரு திரட்டலிலும் ஒரே மாதிரியாக செயல்படுத்தப்படாமல் போகலாம், இதனால் சரியாக வேலை செய்யாது.\n\n ஏதேனும் ஒத்திசைவு சிக்கலை நீங்கள் சந்தித்தால், தயவுசெய்து அவற்றை அறிவிலிமையம் களஞ்சியத்தை ரீட் ராப் செய்ய புகாரளிக்கவும். + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 000000000..8f8da586f --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,227 @@ + + + 连接错误 + 添加订阅 + 订阅 URL + 字段不能为空 + 输入的不是有效的 URL + 确认 + 未知主机 + %1$s 分钟 + 少于 1 分钟 + 1 分钟 + 分享文章 + 打开 URL + 添加文件夹 + 取消 + 访问订阅 %1$s 时出现网络故障 + 解析订阅 %1$s 时失败 + 订阅 %1$s 格式错误 + 文章 + 显示已读文章 + 标记为未读 + 标记为已读 + 账号 URL + 账号名称 + 登录 + 密码 + 这是您的 FreshRSS API 密码(设置 > 账号) + 添加账号 + 无订阅 + 编辑文件夹 + 删除文件夹? + 删除账号 + 删除账号? + 已删除订阅 %1$s + 文件夹已存在 + 服务器上不存在此文件夹 + 凭据 + 最新到最旧 + 新账号 + 本地 + 删除 + 应用根据 GPLv3 许可发布 + 获取订阅颜色 + 全局 + 打开内容 + 外部网络浏览器 + 分享 URL + OPML 导入/导出 + 导入 OPML + 导出 OPML + 图片选项 + 下载图片 + 分享图片 + 主题 + 浅色 + 深色 + 新订阅 + 自动同步 + 30 分钟 + 1 小时 + 2 小时 + 3 小时 + 已禁用自动同步 + 打开 + 返回 + 保存 + 同步 + 应用内浏览器 + 收藏 + 系统主题 + 隐藏没有新文章的订阅 + 滚动时自动将文章标记为已读‌ + 新文章 + 筛选器 + 详情 + 同步错误 + + 以下订阅出现了错误: + + 无法访问订阅,HTTP 错误 %1$s + 处理订阅错误 + 无法访问的 URL + HTTP 错误 400,请检查您的服务器 URL + HTTP 错误 401,请检查您的凭据 + An error occurred + HTTP 错误 404,未找到 URL + HTTP 错误 %1$d,服务器错误 + HTTP 错误 %1$d + 开放源代码库 + 捐赠 + 其他账号 + 重命名账号 + 首选项 + 时间线 + 没有未读文章的订阅将隐藏在抽屉中 + 紧凑 + 常规 + + 后台同步已在运行 + 可以帮助后台同步不被系统终止 + 已为此应用禁用电池优化 + OPML 导出成功 + 添加到收藏 + 授予通知访问权限 + 当前已禁用系统通知,点击此处启用通知 + 无文章 + 是否要删除订阅 %1$s? + 是否要删除文件夹 %1$s?将会删除所有关联的订阅。 + 更新颜色 + 将所有文章标记为已读 + 是否确定要将所有文章标记为已读? + Readrops 崩溃了。 + 在 GitHub 上报告错误 + 将错误消息复制到剪贴板 + 已复制! + 比特币(复制地址) + 莱特币(复制地址) + 如果您发现我的工作很有用并且愿意支持我,您可以考虑向我捐款。 + 请提供完整的 API URL + 请提供服务根 URL + 标识符 + 日期 + 升序 + 降序 + 排序方式 + 选择文件夹 + 排序方向 + (已选择 %1$s) + 添加 %1$s 个所选订阅 + 输入 URL + 订阅已存在 + 无效的文件夹 + 无效的订阅 + 下载图片时出错 + 启用通知 + 当前已禁用账号通知 + 打开订阅 + 本地视图 + 外部视图 + 不要再问我下一个订阅 + 默认类别 + 启动时同步 + 选择颜色 + 重置 + 操作 + 从图标重新加载颜色 + 重新加载图标颜色时出错 + 警告 + 添加订阅 + 设置 + 无文件夹 + 订阅 %1$s 添加成功 + 编辑订阅 + 删除订阅? + 账号 + 登录失败,请检查您的凭据 + 每天 + 通知 + 要显示通知,需要激活后台同步。\n是否要打开设置? + RSS 订阅始终包含旧文章,如果按日期排序,您可能不会在时间线中看到这些文章。按文章标识符排序可以显示所有新插入的文章,无论其日期如何。 + 订阅名称 + 订阅 %1$s 出现未知错误 + 服务器上不存在订阅 %1$s + 更新 + 选择账号 + 12 小时 + 出错了 + 最旧到最新 + 禁用电池优化 + 无法打开文件 + 手动 + 6 小时 + 账号同步 + %1$s 篇新文章 + 名称 + 网络故障:%1$s + HTTP 错误 403,禁止访问 + HTTP 错误 %1$d,请检查您的字段 + 文章大小 + 此 API 不支持修改订阅 URL + 正在更新 %1$s 账号 + 全部启用 + 未找到 RSS 订阅 + 全部禁用 + URL + %1$d 篇未读 + 文章视图 + 后台同步完成后显示通知 + 更多 + 外部 + 后台同步完成后收到新文章的通知 + 提供的 URL 不是有效的 RSS 订阅 + 优先显示最旧的文章 + 已下载文件! + 从新图标加载颜色 + 订阅颜色 + 预览 + 订阅源 + 使用默认颜色 + 您看见此消息意味着本应用遇到了意外错误。如果可以,请在 GitHub 上报告错误。 + 知道了 + API + 自定义分享到其他应用时的文本 + 预览分享的文本 + 编辑分享的文本模板 + 各位,有个好消息! + 很棒的内容创建者 + 上周在地球上发生了这件事。\n\n这是本周发生的最棒的事情。 + 此选项允许您自定义与其他 Android 应用程序分享文章时文本,请<a href=https://pebbletemplates.io/wiki/guide/basic-usage/#syntax-reference>在此处查看可用的语法</a>。 + 上下文中可用的变量为 %s。除 %s 外,您还可以使用<a href=https://pebbletemplates.io/wiki/>此处记录</a>的所有模板筛选器。 + 当应用于任意变量时,将在 %s 前插入一个不间断空格 + 使用默认模板填充 + 查看结果 + 编辑模板 + %s:%s + 已禁用 + 向左滑动操作 + 是否要删除文件夹 %1$s?所有关联的订阅将移至默认文件夹。 + 没有可用的网络连接 + Google Reader 是非常古老的 API,且缺乏文档。在每个聚合器的实现方式可能不尽相同,因此可能无法很好地运行。\n\n如果遇到任何同步问题,请向 Readrops GitHub 仓库报告。 + Fever 是非常古老、有限和过时的 API。它不支持 Feed 和文件夹管理。此外,它的文档也不完善,在每个聚合器的实现方式可能不尽相同,因此可能无法很好地运行。\n\n如果遇到任何同步问题,请向 Readrops GitHub 仓库报告。 + 当应用于任意变量时,此筛选器将从中移除变量 author 的内容 + “%s” + 向右滑动操作 + 文件为空 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..f34d31877 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,180 @@ + + + 取消 + 新增資料夾 + 沒有資料夾 + 標記為未讀 + 標記為已讀 + 登入帳號 + 密碼 + 新增帳戶 + 帳戶名稱 + 帳戶 + 編輯資料夾 + 選擇帳戶 + 刪除帳戶 + 新到舊 + 舊到新 + 本機 + 全域 + 此應用程式在 GPLv3 授權條款下發佈 + 刪除 + 下載圖片 + 圖片選項 + 匯入/匯出 OPML + 匯入 OPML + 匯出 OPML + 主題 + 亮色 + 暗色 + 自動同步 + 每 30 分鐘 + 每 1 小時 + 手動 + 每 3 小時 + 自動同步已經關閉 + 我的最愛 + HTTP 錯誤 %1$d + URL + + 顯示大小 + + 電池最佳化已關閉 + 成功匯出 OPML + 名稱 + 設定 + 每 2 小時 + 每 12 小時 + 外部網路瀏覽器 + 新增至最愛 + 每 24 小時 + 重新命名帳戶 + 在 Github 上回報錯誤 + API + 每 6 小時 + 選擇資料夾 + 分享圖片 + Litecoin (複製錢包地址) + 關閉電池最佳化 + 跟隨系統 + Bitcoin (複製錢包地址) + 新帳戶 + 如果您看見此訊息,意味著應用程式遇到意料之外的錯誤。有空的話請您幫忙在 Github 上回報錯誤。 + 無法開啟檔案 + 通知 + 確定要刪除帳戶? + 其他帳戶 + + 新增 feed + 確定要刪除 feed? + 這是你的 FreshRSS API 密碼 ( 帳號>使用者資訊 ) + 沒有 feed + 確定要刪除資料夾? + Feeds + 新 feed + HTTP 錯誤 403,禁止存取 + HTTP 錯誤 404,找不到 URL + 這個 API 不支援修改 Feed URL + 在背景同步完成後顯示通知 + 選擇顏色 + 使用預設顏色 + 如果您覺得我編寫的這個應用程式還不錯並且想支持我的話,可以考慮一下捐款。 + 新增 feed + Feed URL + 確認 + 未知主機 + 1 分鐘 + 短於 1 分鐘 + %1$s 分鐘 + 連線錯誤 + 欄位不能是空的 + 輸入的 URL 不可用 + 編輯 feed + Feed 名稱 + 開啟 URL + 帳戶 URL + 文章 + 發生錯誤 + 身份驗證資訊 + 登入失敗。請檢查您的身份驗證資訊 + 資料夾已經存在 + 資料夾不存在於伺服器端 + 分享 URL + 帳戶同步 + 內建網路瀏覽器 + 篩選器 + 同步錯誤 + HTTP 錯誤 400,請檢查您的伺服器 URL + HTTP 錯誤 401,請檢查您的身份驗證資訊 + HTTP 錯誤 %1$d,請檢查您填於欄位的資訊是否正確 + 全部啟用 + 全部停用 + 捐款 + 開放原始碼函式庫 + %1$d 未讀 + 偏好 + Readrops 崩潰了。 + 將錯誤訊息複製至剪貼簿 + 已複製! + 請提供完整的 API URL + 日期 + 請輸入提供服務的 URL + Feed 已經存在 + 輸入一個 URL + 帳戶通知當前處於停用狀態 + 啟用通知 + 預覽 + 找不到 RSS feed + HTTP 錯誤 %1$d, 伺服器端錯誤 + 停用 + 分享文章 + 存取 feed %1$s 時發生網路錯誤 + 成功新增 feed %1$s + 解析 feed %1$s 失敗 + Feed %1$s 發生未知錯誤 + 顯示已讀文章 + 已刪除 feed %1$s + 伺服器端不存在 feed %1$s + %1$s 新文章 + 同步 + 新文章 + An error occurred + 無法存取的 feed,HTTP 錯誤 %1$s + + 以下 feed(s) 發生一個或多個錯誤: + + 無法存取的 URL + 時間軸 + 可以幫助背景同步不被系統終止 + 背景同步已經在執行中 + 背景同步完成後通知新文章 + 您真的想要將所有文章標記為已讀嗎? + 將所有文章標記為已讀 + 檔案已下載! + 不可用的 feed + 不可用的資料夾 + 下載圖片時發生錯誤 + 動作 + 沒有文章 + 處理 feed 時發生錯誤 + 網路錯誤: %1$s + Feed %1$s 格式錯誤 + 授予此應用程式存取通知的權限 + Feed 顏色 + 警告 + 在應用程式啟動時同步 + Google Reader API 非常老舊且缺乏文檔,每個聚合器實作方式可能都不一樣,因此可能不起作用。\n\n如果您遇到任何同步問題,請將其回報至 Readrops 的 Github 倉庫。 + Fever API 非常老舊、備受限制且已被棄用,不支持管理 feed 和資料夾,文檔狀態也很糟糕,每個聚合器實作方式可能都不一樣,因此可能不起作用。\n\n如果您遇到任何同步問題,請將其回報至 Readrops 的 Github 倉庫。 + 重置 + 我了解 + 預設類別 + 好消息,各位! + 令人驚嘆的內容創作者 + 檔案是空的 + 沒有可用的網路連線 + 查看結果 + 您確定要將資料夾 %1$s 刪除? 所有與之連結的 feeds 都會被移動至預設資料夾。 + 您提供的 URL 並非可用的 RSS feed + 您確定要將資料夾 %1$s 刪除? 所有與之連結的 feeds 都會一起被刪除。 + 您確定要將 feed %1$s 刪除? + diff --git a/app/src/main/res/values/html.xml b/app/src/main/res/values/html.xml index e641e50fd..376210d47 100644 --- a/app/src/main/res/values/html.xml +++ b/app/src/main/res/values/html.xml @@ -1,6 +1,12 @@ - + @@ -9,14 +15,6 @@