diff options
| -rw-r--r-- | .coverage-baseline | 2 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/data/Settings.kt | 19 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt | 28 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt | 11 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt | 32 | ||||
| -rw-r--r-- | config/detekt/baseline.xml | 8 |
6 files changed, 79 insertions, 21 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 3125d73..2a43146 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -2.31 +2.42 diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt index 2e7eaae..be3e809 100644 --- a/app/src/main/java/org/terst/feedreader/data/Settings.kt +++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt @@ -28,8 +28,16 @@ private object SettingsKeys { val LAST_FETCH_AT = longPreferencesKey("last_fetch_at") val LAST_FETCH_NEW_ARTICLES = intPreferencesKey("last_fetch_new_articles") val LAST_ACKNOWLEDGED_FETCH_AT = longPreferencesKey("last_acknowledged_fetch_at") + val READER_FONT_SIZE_PX = intPreferencesKey("reader_font_size_px") } +// Matches the size ReaderWebView hardcoded before this setting existed, so an upgrading +// user's reader looks exactly the same until they deliberately change it. +const val DEFAULT_READER_FONT_SIZE_PX = 18 +private const val READER_FONT_SIZE_MIN_PX = 14 +private const val READER_FONT_SIZE_MAX_PX = 28 +val READER_FONT_SIZE_RANGE_PX = READER_FONT_SIZE_MIN_PX..READER_FONT_SIZE_MAX_PX + /** Stash connection settings, configured on the Settings screen. Both blank until set. */ class Settings(private val context: Context) { val stashBaseUrl: Flow<String> = @@ -109,4 +117,15 @@ class Settings(private val context: Context) { suspend fun acknowledgeFetch(at: Long) { context.settingsDataStore.edit { prefs -> prefs[SettingsKeys.LAST_ACKNOWLEDGED_FETCH_AT] = at } } + + val readerFontSizePx: Flow<Int> = + context.settingsDataStore.data.map { it[SettingsKeys.READER_FONT_SIZE_PX] ?: DEFAULT_READER_FONT_SIZE_PX } + + suspend fun readerFontSizePxNow(): Int = readerFontSizePx.first() + + suspend fun setReaderFontSizePx(px: Int) { + context.settingsDataStore.edit { prefs -> + prefs[SettingsKeys.READER_FONT_SIZE_PX] = px.coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last) + } + } } diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index 3c3ab66..a89052c 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,6 +57,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.data.Settings import org.terst.feedreader.data.ensureStandaloneArticle @@ -99,6 +101,7 @@ fun ReaderScreen( val db = remember { AppDatabase.get(context) } val settings = remember { Settings(context) } val scope = rememberCoroutineScope() + val fontSizePx by settings.readerFontSizePx.collectAsState(initial = DEFAULT_READER_FONT_SIZE_PX) var previousId by remember(articleId) { mutableStateOf<String?>(null) } var nextId by remember(articleId) { mutableStateOf<String?>(null) } @@ -156,7 +159,8 @@ fun ReaderScreen( hasNext = nextId != null, onPrevious = ::goToPreviousArticle, onNext = ::goToNextArticle, - onNextKeepUnread = ::goToNextArticleKeepUnread + onNextKeepUnread = ::goToNextArticleKeepUnread, + fontSizePx = fontSizePx ) } @@ -171,7 +175,8 @@ private fun ReaderContent( hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, - onNextKeepUnread: () -> Unit + onNextKeepUnread: () -> Unit, + fontSizePx: Int ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } @@ -516,8 +521,11 @@ private fun ReaderContent( // flipping while a Stash save is in flight. Reloading the WebView's HTML // on those unrelated recompositions was resetting scroll position mid-swipe, // which is what made scrolling feel unsmooth/janky. Track what's actually - // loaded and only reload when the content string itself changes. - val loadedContent = remember(articleId) { mutableStateOf<String?>(null) } + // loaded and only reload when the content string or font size actually + // changes -- fontSizePx is included deliberately (not just content) so + // changing it in Settings and coming back to an already-open article + // picks up the new size, without reloading on every unrelated recomposition. + val loadedKey = remember(articleId) { mutableStateOf<Pair<String?, Int>?>(null) } AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> @@ -532,14 +540,16 @@ private fun ReaderContent( // to 0 on a page that hasn't grown tall enough yet. onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) } ).apply { - loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null) - loadedContent.value = content + val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx) + loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null) + loadedKey.value = content to fontSizePx } }, update = { webView -> - if (loadedContent.value != content) { - webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null) - loadedContent.value = content + if (loadedKey.value != content to fontSizePx) { + val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx) + webView.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null) + loadedKey.value = content to fontSizePx } }, // AndroidView does not destroy() the underlying WebView on its own -- diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index 31fcb02..b13fc58 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX // In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my // place if Next/Previous takes me away and back," not to surviving an app restart. A @@ -344,7 +345,13 @@ private fun escapeHtml(text: String): String = text // on whatever heading Readability happened to keep in bodyContent (increasingly nothing -- // see ReaderExtractor's <header> stripping) or on the top app bar's title, which truncates // to one line and was the only place a long title showed up before this. -fun wrapReaderHtml(bodyContent: String, title: String? = null, publishedAt: Long? = null, imageUrl: String? = null): String { +fun wrapReaderHtml( + bodyContent: String, + title: String? = null, + publishedAt: Long? = null, + imageUrl: String? = null, + fontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX +): String { val processedBody = replaceGenericIframes(replaceYouTubeIframes(bodyContent)) // Some feeds' body HTML never embeds an image at all -- the only image they give us // is a separate RSS media:content/enclosure field (ArticleEntity.imageUrl), which @@ -369,7 +376,7 @@ fun wrapReaderHtml(bodyContent: String, title: String? = null, publishedAt: Long return """ <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> <style> - body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; + body { font-family: sans-serif; font-size: ${fontSizePx}px; line-height: 1.5; padding: 16px; background-color: #121212; color: #e1e1e1; } h1.article-title { font-size: 1.5em; line-height: 1.3; margin: 0 0 0.6em 0; } p.article-date { font-size: 0.85em; color: #9aa0a6; margin: -0.4em 0 1em 0; } diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt index 5f8eca7..966d36b 100644 --- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -44,13 +45,16 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.data.FilterAction import org.terst.feedreader.data.FilterRuleEntity import org.terst.feedreader.data.Net +import org.terst.feedreader.data.READER_FONT_SIZE_RANGE_PX import org.terst.feedreader.data.Settings import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashVerifyResult +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,9 +69,11 @@ fun SettingsScreen(onBack: () -> Unit) { var stashKeyVisible by remember { mutableStateOf(false) } var isVerifyingStash by remember { mutableStateOf(false) } var stashStatus by remember { mutableStateOf<String?>(null) } + var fontSizePx by remember { mutableStateOf(DEFAULT_READER_FONT_SIZE_PX) } LaunchedEffect(Unit) { stashUrl = settings.stashBaseUrlNow() stashKey = settings.stashApiKeyNow() + fontSizePx = settings.readerFontSizePxNow() } var newRulePattern by remember { mutableStateOf("") } @@ -98,10 +104,9 @@ fun SettingsScreen(onBack: () -> Unit) { Text("FeeDreader", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(4.dp)) Text( - "If a site paywalls its own feed, that's the site's call. But this reader " + - "won't go out of its way to help a soft paywall block you from reading " + - "something you already subscribed to get -- reader mode extracts the " + - "article you were sent, nothing more, nothing sneaky.", + "FeeDreader exists to avoid tracking, manipulation, and junk fees. If an " + + "article's full text is something you could just as easily find with a " + + "search, reader mode will pull it up here too.", style = MaterialTheme.typography.bodyMedium ) } @@ -110,6 +115,25 @@ fun SettingsScreen(onBack: () -> Unit) { item { Column(modifier = Modifier.padding(16.dp)) { + Text("Reading", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text("Font size: ${fontSizePx}px", style = MaterialTheme.typography.bodyMedium) + Slider( + value = fontSizePx.toFloat(), + onValueChange = { fontSizePx = it.roundToInt() }, + valueRange = READER_FONT_SIZE_RANGE_PX.first.toFloat()..READER_FONT_SIZE_RANGE_PX.last.toFloat(), + steps = READER_FONT_SIZE_RANGE_PX.last - READER_FONT_SIZE_RANGE_PX.first - 1, + // Persist on release, not on every onValueChange -- a drag crosses many + // integer values in a fraction of a second, and there's no reason to hit + // DataStore that often for a value nothing reads until the next article load. + onValueChangeFinished = { scope.launch { settings.setReaderFontSizePx(fontSizePx) } } + ) + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { Text("Stash", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index bd02fbe..9b48174 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -8,7 +8,7 @@ <ID>CyclomaticComplexMethod:FeedParser.kt$FeedParser$private fun parseAtom(parser: XmlPullParser): ParsedFeed</ID> <ID>CyclomaticComplexMethod:FeedParser.kt$FeedParser$private fun parseRss(parser: XmlPullParser): ParsedFeed</ID> <ID>CyclomaticComplexMethod:LinkReaderScreen.kt$@SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit)</ID> - <ID>CyclomaticComplexMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit )</ID> + <ID>CyclomaticComplexMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit, fontSizePx: Int )</ID> <ID>LongMethod:AddFeedScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddFeedScreen(onDone: () -> Unit)</ID> <ID>LongMethod:ArticleListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable fun ArticleListScreen( feedId: String, onOpenArticle: (String) -> Unit, onBack: () -> Unit, onOpenFeedList: () -> Unit )</ID> <ID>LongMethod:FeedFetchWorker.kt$FeedFetchWorker$private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase): Int</ID> @@ -18,12 +18,12 @@ <ID>LongMethod:LinkReaderScreen.kt$@SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit)</ID> <ID>LongMethod:MainActivity.kt$@Composable fun FeedReaderApp()</ID> <ID>LongMethod:ReaderExtractor.kt$ReaderExtractor.<no name provided>$override fun onPageFinished(view: WebView, finishedUrl: String)</ID> - <ID>LongMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit )</ID> + <ID>LongMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit, fontSizePx: Int )</ID> <ID>LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(onBack: () -> Unit)</ID> <ID>LongParameterList:ArticleListScreen.kt$( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, showReadState: Boolean, onClick: () -> Unit, onMarkReadFrom: () -> Unit )</ID> <ID>LongParameterList:Daos.kt$FeedDao$( id: String, title: String, siteUrl: String?, lastFetchedAt: Long, etag: String?, lastModified: String?, lastError: String? )</ID> <ID>LongParameterList:FeedListScreen.kt$( onOpenFeed: (String) -> Unit, onOpenUnread: () -> Unit, onOpenOlder: () -> Unit, onOpenSaved: () -> Unit, onAddFeed: () -> Unit, onSettings: () -> Unit )</ID> - <ID>LongParameterList:ReaderScreen.kt$( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit )</ID> + <ID>LongParameterList:ReaderScreen.kt$( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit, fontSizePx: Int )</ID> <ID>LongParameterList:ReaderScreen.kt$( feedId: String, articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, onNavigateToArticle: (String) -> Unit )</ID> <ID>LongParameterList:ReaderWebView.kt$( context: Context, onOpenLink: (String) -> Unit, onLongPressLink: (String) -> Unit = {}, onYouTubeVideo: (String) -> Unit = {}, onScrollChanged: (Int) -> Unit = {}, onPageFinished: (WebView) -> Unit = {} )</ID> <ID>LongParameterList:ReaderWebView.kt$( url: String, onDismiss: () -> Unit, onOpenInBrowser: () -> Unit, onShare: () -> Unit, onSaveToStash: () -> Unit, onSaveForLater: () -> Unit )</ID> @@ -54,8 +54,6 @@ <ID>MaxLineLength:Entities.kt$ArticleEntity$val readAt: Long? = null</ID> <ID>MaxLineLength:LinkReaderScreen.kt$loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)</ID> <ID>MaxLineLength:LinkReaderScreen.kt$webView.loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)</ID> - <ID>MaxLineLength:ReaderScreen.kt$loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)</ID> - <ID>MaxLineLength:ReaderScreen.kt$webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)</ID> <ID>MaxLineLength:ReaderWebView.kt$"<figure><a href=\"https://www.youtube.com/watch?v=$id\"><img src=\"https://i.ytimg.com/vi/$id/hq720.jpg\" alt=\"YouTube video\"></a><figcaption>▶ Watch on YouTube</figcaption></figure>"</ID> <ID>MaxLineLength:SettingsScreen.kt$DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false })</ID> <ID>MaxLineLength:SettingsScreen.kt$DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false })</ID> |
