From c8aae455e249452fca6ca6d476f96b25f8f02b4e Mon Sep 17 00:00:00 2001 From: Ash Date: Wed, 10 Jul 2024 19:07:22 +0800 Subject: [PATCH] refactor(reading): re-enable webview renderer --- .../html/RYArticleGrabberExtended.kt | 86 ++++ .../reader/infrastructure/html/Readability.kt | 24 +- .../infrastructure/preference/Preference.kt | 1 + .../ReadingBionicReadingPreference.kt | 44 ++ .../ReadingTextLineHeightPreference.kt | 4 +- .../preference/ReadingThemePreference.kt | 8 +- .../infrastructure/preference/Settings.kt | 2 + .../component/base/CanBeDisabledIconButton.kt | 21 +- .../ash/reader/ui/component/base/RYWebView.kt | 396 ------------------ .../ui/component/webview/BionicReadingIcon.kt | 59 +++ .../component/webview/JavaScriptInterface.kt | 14 + .../reader/ui/component/webview/RYWebView.kt | 124 ++++++ .../ui/component/webview/WebViewClient.kt | 96 +++++ .../ui/component/webview/WebViewHtml.kt | 29 ++ .../ui/component/webview/WebViewLayout.kt | 39 ++ .../ui/component/webview/WebViewScript.kt | 95 +++++ .../ui/component/webview/WebViewStyle.kt | 298 +++++++++++++ .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 2 + .../me/ash/reader/ui/page/common/HomeEntry.kt | 7 +- .../me/ash/reader/ui/page/common/RouteName.kt | 1 + .../reader/ui/page/home/reading/BottomBar.kt | 44 +- .../reader/ui/page/home/reading/Content.kt | 9 +- .../ui/page/home/reading/ReadingPage.kt | 15 +- .../reader/ui/page/settings/SettingItem.kt | 15 +- .../color/reading/BionicReadingPage.kt | 140 +++++++ .../color/reading/ReadingStylePage.kt | 39 +- app/src/main/res/values/strings.xml | 10 +- 27 files changed, 1176 insertions(+), 446 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/infrastructure/html/RYArticleGrabberExtended.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/ReadingBionicReadingPreference.kt delete mode 100644 app/src/main/java/me/ash/reader/ui/component/base/RYWebView.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/BionicReadingIcon.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/JavaScriptInterface.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/RYWebView.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/WebViewClient.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/WebViewHtml.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/WebViewLayout.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/WebViewScript.kt create mode 100644 app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/color/reading/BionicReadingPage.kt diff --git a/app/src/main/java/me/ash/reader/infrastructure/html/RYArticleGrabberExtended.kt b/app/src/main/java/me/ash/reader/infrastructure/html/RYArticleGrabberExtended.kt new file mode 100644 index 000000000..a545b7468 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/html/RYArticleGrabberExtended.kt @@ -0,0 +1,86 @@ +package me.ash.reader.infrastructure.html + + +import net.dankito.readability4j.extended.processor.ArticleGrabberExtended +import net.dankito.readability4j.extended.util.RegExUtilExtended +import net.dankito.readability4j.model.ArticleGrabberOptions +import net.dankito.readability4j.model.ReadabilityOptions +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode + +open class RYArticleGrabberExtended(options: ReadabilityOptions, regExExtended: RegExUtilExtended) : ArticleGrabberExtended(options, regExExtended) { + + override fun prepareNodes(doc: Document, options: ArticleGrabberOptions): List { + val elementsToScore = ArrayList() + var node: Element? = doc + + while(node != null) { + val matchString = node.className() + " " + node.id() + + // Check to see if this node is a byline, and remove it if it is. + if(checkByline(node, matchString)) { + node = removeAndGetNext(node, "byline") + continue + } + + // Remove unlikely candidates + if(options.stripUnlikelyCandidates) { + if(regEx.isUnlikelyCandidate(matchString) && + regEx.okMaybeItsACandidate(matchString) == false && + node.tagName() != "body" && + node.tagName() != "a") { + node = this.removeAndGetNext(node, "Removing unlikely candidate") + continue + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if((node.tagName() == "div" || node.tagName() == "section" || node.tagName() == "header" || + node.tagName() == "h1" || node.tagName() == "h2" || node.tagName() == "h3" || + node.tagName() == "h4" || node.tagName() == "h5" || node.tagName() == "h6") && + this.isElementWithoutContent(node)) { + node = this.removeAndGetNext(node, "node without content") + continue + } + + if(DEFAULT_TAGS_TO_SCORE.contains(node.tagName())) { + elementsToScore.add(node) + } + + // Turn all divs that don't have children block level elements into p's + if(node.tagName() == "div") { + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if(this.hasSinglePInsideElement(node)) { + val newNode = node.child(0) + node.replaceWith(newNode) + node = newNode + elementsToScore.add(node) + } + else if(!this.hasChildBlockElement(node)) { + setNodeTag(node, "p") + elementsToScore.add(node) + } + else { + node.childNodes().forEach { childNode -> + if(childNode is TextNode && childNode.text().trim().length > 0) { + val p = doc.createElement("p") + p.text(childNode.text()) + // EXPERIMENTAL + // p.attr("style", "display: inline;") + // p.addClass("readability-styled") + childNode.replaceWith(p) + } + } + } + } + + node = if(node != null) this.getNextNode(node) else null + } + + return elementsToScore + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/html/Readability.kt b/app/src/main/java/me/ash/reader/infrastructure/html/Readability.kt index 31c37c573..2c140a7db 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/html/Readability.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/html/Readability.kt @@ -2,6 +2,11 @@ package me.ash.reader.infrastructure.html import android.util.Log import net.dankito.readability4j.extended.Readability4JExtended +import net.dankito.readability4j.extended.processor.PostprocessorExtended +import net.dankito.readability4j.extended.util.RegExUtilExtended +import net.dankito.readability4j.model.ReadabilityOptions +import net.dankito.readability4j.processor.MetadataParser +import net.dankito.readability4j.processor.Preprocessor import org.jsoup.nodes.Element object Readability { @@ -9,7 +14,7 @@ object Readability { fun parseToText(htmlContent: String?, uri: String?): String { htmlContent ?: return "" return try { - Readability4JExtended(uri ?: "", htmlContent).parse().textContent?.trim() ?: "" + Readability4JExtended(uri, htmlContent).parse().textContent?.trim() ?: "" } catch (e: Exception) { Log.e("RLog", "Readability.parseToText '$uri' is error: ", e) "" @@ -19,10 +24,25 @@ object Readability { fun parseToElement(htmlContent: String?, uri: String?): Element? { htmlContent ?: return null return try { - Readability4JExtended(uri ?: "", htmlContent).parse().articleContent + Readability4JExtended(uri, htmlContent).parse().articleContent } catch (e: Exception) { Log.e("RLog", "Readability.parseToElement '$uri' is error: ", e) null } } + + private fun Readability4JExtended(uri: String?, html: String): Readability4JExtended { + val options = ReadabilityOptions() + val regExUtil = RegExUtilExtended() + return Readability4JExtended( + uri = uri ?: "", + html = html, + options = options, + regExUtil = regExUtil, + preprocessor = Preprocessor(regExUtil), + metadataParser = MetadataParser(regExUtil), + articleGrabber = RYArticleGrabberExtended(options, regExUtil), + postprocessor = PostprocessorExtended(), + ) + } } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt index 801c7a09e..a2802814d 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt @@ -54,6 +54,7 @@ fun Preferences.toSettings(): Settings { // Reading page readingRenderer = ReadingRendererPreference.fromPreferences(this), + readingBionicReading = ReadingBionicReadingPreference.fromPreferences(this), readingTheme = ReadingThemePreference.fromPreferences(this), readingDarkTheme = ReadingDarkThemePreference.fromPreferences(this), readingPageTonalElevation = ReadingPageTonalElevationPreference.fromPreferences(this), diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingBionicReadingPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingBionicReadingPreference.kt new file mode 100644 index 000000000..391e3e526 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingBionicReadingPreference.kt @@ -0,0 +1,44 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.readingBionicReading +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalReadingBionicReading = + compositionLocalOf { ReadingBionicReadingPreference.default } + +sealed class ReadingBionicReadingPreference(val value: Boolean) : Preference() { + object ON : ReadingBionicReadingPreference(true) + object OFF : ReadingBionicReadingPreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { + context.dataStore.put(readingBionicReading, value) + } + } + + companion object { + + val default = OFF + val values = listOf(ON, OFF) + + fun fromPreferences(preferences: Preferences) = + when (preferences[DataStoreKey.keys[readingBionicReading]?.key as Preferences.Key]) { + true -> ON + false -> OFF + else -> default + } + } +} + +operator fun ReadingBionicReadingPreference.not(): ReadingBionicReadingPreference = + when (value) { + true -> ReadingBionicReadingPreference.OFF + false -> ReadingBionicReadingPreference.ON + } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt index 1d8213919..671a03cc5 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTextLineHeightPreference.kt @@ -13,8 +13,8 @@ import me.ash.reader.ui.ext.put val LocalReadingTextLineHeight = compositionLocalOf { ReadingTextLineHeightPreference.default } data object ReadingTextLineHeightPreference { - const val default = 1f - private val range = 0.8f..2f + const val default = 1.5F + private val range = 0.8F..2F fun put(context: Context, scope: CoroutineScope, value: Float) { scope.launch { diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingThemePreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingThemePreference.kt index d63978296..88110d686 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingThemePreference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingThemePreference.kt @@ -50,6 +50,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() { ReadingTextHorizontalPaddingPreference.default) ReadingTextAlignPreference.default.put(context, scope) ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default) + ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default) ReadingTextFontSizePreference.put(context, scope, ReadingTextFontSizePreference.default) ReadingImageRoundedCornersPreference.put(context, scope, ReadingImageRoundedCornersPreference.default) ReadingImageHorizontalPaddingPreference.put(context, scope, @@ -69,7 +70,8 @@ sealed class ReadingThemePreference(val value: Int) : Preference() { ReadingTextHorizontalPaddingPreference.default) ReadingTextAlignPreference.default.put(context, scope) ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default) - ReadingTextFontSizePreference.put(context, scope, 18) + ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default) + ReadingTextFontSizePreference.put(context, scope, 22) ReadingImageRoundedCornersPreference.put(context, scope, 0) ReadingImageHorizontalPaddingPreference.put(context, scope, 0) ReadingImageMaximizePreference.default.put(context, scope) @@ -87,6 +89,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() { ReadingTextHorizontalPaddingPreference.default) ReadingTextAlignPreference.Center.put(context, scope) ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default) + ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default) ReadingTextFontSizePreference.put(context, scope, 20) ReadingImageRoundedCornersPreference.put(context, scope, 0) ReadingImageHorizontalPaddingPreference.put(context, scope, @@ -106,6 +109,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() { ReadingTextHorizontalPaddingPreference.default) ReadingTextAlignPreference.default.put(context, scope) ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default) + ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default) ReadingTextFontSizePreference.put(context, scope, ReadingTextFontSizePreference.default) ReadingImageRoundedCornersPreference.put(context, scope, ReadingImageRoundedCornersPreference.default) ReadingImageHorizontalPaddingPreference.put(context, scope, @@ -117,7 +121,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() { companion object { - val default = MaterialYou + val default = Reeder val values = listOf(MaterialYou, Reeder, Paper, Custom) fun fromPreferences(preferences: Preferences): ReadingThemePreference = diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt index 56de1ac3e..e3e0ef422 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt @@ -52,6 +52,7 @@ data class Settings( // Reading page val readingRenderer: ReadingRendererPreference = ReadingRendererPreference.default, + val readingBionicReading: ReadingBionicReadingPreference = ReadingBionicReadingPreference.default, val readingTheme: ReadingThemePreference = ReadingThemePreference.default, val readingDarkTheme: ReadingDarkThemePreference = ReadingDarkThemePreference.default, val readingPageTonalElevation: ReadingPageTonalElevationPreference = ReadingPageTonalElevationPreference.default, @@ -142,6 +143,7 @@ fun SettingsProvider( // Reading page LocalReadingRenderer provides settings.readingRenderer, + LocalReadingBionicReading provides settings.readingBionicReading, LocalReadingTheme provides settings.readingTheme, LocalReadingDarkTheme provides settings.readingDarkTheme, LocalReadingPageTonalElevation provides settings.readingPageTonalElevation, diff --git a/app/src/main/java/me/ash/reader/ui/component/base/CanBeDisabledIconButton.kt b/app/src/main/java/me/ash/reader/ui/component/base/CanBeDisabledIconButton.kt index 10388f93d..2af3e8cfe 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/CanBeDisabledIconButton.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/CanBeDisabledIconButton.kt @@ -17,7 +17,8 @@ import androidx.compose.ui.unit.dp fun CanBeDisabledIconButton( modifier: Modifier = Modifier, disabled: Boolean, - imageVector: ImageVector, + imageVector: ImageVector? = null, + icon: @Composable () -> Unit = {}, size: Dp = 24.dp, contentDescription: String?, tint: Color = LocalContentColor.current, @@ -34,11 +35,15 @@ fun CanBeDisabledIconButton( enabled = !disabled, onClick = onClick, ) { - Icon( - modifier = Modifier.size(size), - imageVector = imageVector, - contentDescription = contentDescription, - tint = if (disabled) MaterialTheme.colorScheme.outline else tint, - ) + if (imageVector != null) { + Icon( + modifier = Modifier.size(size), + imageVector = imageVector, + contentDescription = contentDescription, + tint = if (disabled) MaterialTheme.colorScheme.outline else tint, + ) + } else { + icon() + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/ash/reader/ui/component/base/RYWebView.kt b/app/src/main/java/me/ash/reader/ui/component/base/RYWebView.kt deleted file mode 100644 index 48f86dc86..000000000 --- a/app/src/main/java/me/ash/reader/ui/component/base/RYWebView.kt +++ /dev/null @@ -1,396 +0,0 @@ -package me.ash.reader.ui.component.base - -import android.content.Context -import android.graphics.Color -import android.net.http.SslError -import android.os.Build -import android.util.Log -import android.webkit.SslErrorHandler -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import me.ash.reader.infrastructure.preference.LocalOpenLink -import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser -import me.ash.reader.infrastructure.preference.LocalReadingImageHorizontalPadding -import me.ash.reader.infrastructure.preference.LocalReadingImageRoundedCorners -import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation -import me.ash.reader.infrastructure.preference.LocalReadingSubheadAlign -import me.ash.reader.infrastructure.preference.LocalReadingSubheadBold -import me.ash.reader.infrastructure.preference.LocalReadingTextAlign -import me.ash.reader.infrastructure.preference.LocalReadingTextBold -import me.ash.reader.infrastructure.preference.LocalReadingTextFontSize -import me.ash.reader.infrastructure.preference.LocalReadingTextHorizontalPadding -import me.ash.reader.infrastructure.preference.LocalReadingTextLetterSpacing -import me.ash.reader.ui.ext.openURL -import me.ash.reader.ui.ext.surfaceColorAtElevation -import kotlin.math.absoluteValue - -const val INJECTION_TOKEN = "/android_asset_font/" - -@Composable -fun RYWebView( - modifier: Modifier = Modifier, - content: String, - onReceivedError: (error: WebResourceError?) -> Unit = {}, -) { - val context = LocalContext.current - val maxWidth = LocalConfiguration.current.screenWidthDp.dp.value - val openLink = LocalOpenLink.current - val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current - val tonalElevation = LocalReadingPageTonalElevation.current - val backgroundColor = MaterialTheme.colorScheme - .surfaceColorAtElevation(tonalElevation.value.dp).toArgb() - val bodyColor: Int = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() - val linkColor: Int = MaterialTheme.colorScheme.primary.toArgb() - val subheadColor: Int = MaterialTheme.colorScheme.onSurface.toArgb() - val subheadBold: Boolean = LocalReadingSubheadBold.current.value - val subheadAlign: String = LocalReadingSubheadAlign.current.toTextAlignCSS() - val textBold: Boolean = LocalReadingTextBold.current.value - val textAlign: String = LocalReadingTextAlign.current.toTextAlignCSS() - val textFontSize: Int = LocalReadingTextFontSize.current - val textLetterSpacing: Float = LocalReadingTextLetterSpacing.current - val imageHorizontalPadding: Int = LocalReadingImageHorizontalPadding.current - val textHorizontalPadding: Int = LocalReadingTextHorizontalPadding.current - val imageShape: Int = LocalReadingImageRoundedCorners.current - val codeColor: Int = MaterialTheme.colorScheme.primary.toArgb() - val codeBackgroundColor: Int = MaterialTheme.colorScheme - .surfaceColorAtElevation((tonalElevation.value + 6).dp).toArgb() - val webViewClient by remember { - mutableStateOf(object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest?, - ): WebResourceResponse? { - val url = request?.url?.toString() - if (url != null && url.contains(INJECTION_TOKEN)) { - try { - val assetPath = url.substring( - url.indexOf(INJECTION_TOKEN) + INJECTION_TOKEN.length, - url.length - ) - return WebResourceResponse( - "text/HTML", - "UTF-8", - context.assets.open(assetPath) - ) - } catch (e: Exception) { - Log.e("RLog", "WebView shouldInterceptRequest: $e") - } - } - return super.shouldInterceptRequest(view, request); - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - val jsCode = "javascript:(function(){" + - "var imgs=document.getElementsByTagName(\"img\");" + - "for(var i=0;i= Build.VERSION_CODES.TIRAMISU) { - isAlgorithmicDarkeningAllowed = true - } - } - // this.loadUrl(url) - this - }) - } - - AndroidView( - modifier = modifier, - factory = { webView }, - update = { - // if (isRefreshing) { - // it.reload() - // setRefreshed() - // } - it.apply { - Log.i("RLog", "maxWidth: ${maxWidth}") - Log.i("RLog", "readingFont: ${context.filesDir.absolutePath}") - Log.i("RLog", "CustomWebView: ${content}") - settings.javaScriptEnabled = true - settings.defaultFontSize = textFontSize - setBackgroundColor(Color.TRANSPARENT) - scrollBarSize = 0 - loadDataWithBaseURL( - null, - getStyle( - context = context, - maxWidth = maxWidth, - bodyColor = bodyColor, - linkColor = linkColor, - subheadColor = subheadColor, - subheadBold = subheadBold, - subheadAlign = subheadAlign, - textBold = textBold, - textAlign = textAlign, - textFontSize = textFontSize, - textLetterSpacing = textLetterSpacing, - imageHorizontalPadding = imageHorizontalPadding, - textHorizontalPadding = textHorizontalPadding, - imageShape = imageShape, - codeColor = codeColor, - codeBackgroundColor = codeBackgroundColor, - ) + content, - "text/HTML", - "UTF-8", null - ) - } - }, - ) -} - -@Stable -fun argbToCssColor(argb: Int): String = String.format("#%06X", 0xFFFFFF and argb) - -@Stable -fun getStyle( - context: Context, - maxWidth: Float, - bodyColor: Int, - linkColor: Int, - subheadColor: Int, - subheadBold: Boolean, - subheadAlign: String, - textBold: Boolean, - textAlign: String, - textFontSize: Int, - textLetterSpacing: Float, - imageHorizontalPadding: Int, - textHorizontalPadding: Int, - imageShape: Int, - codeColor: Int, - codeBackgroundColor: Int, -): String = """ - -""" diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/BionicReadingIcon.kt b/app/src/main/java/me/ash/reader/ui/component/webview/BionicReadingIcon.kt new file mode 100644 index 000000000..47ca90780 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/BionicReadingIcon.kt @@ -0,0 +1,59 @@ +package me.ash.reader.ui.component.webview + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +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.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun BionicReadingIcon( + modifier: Modifier = Modifier, + size: Dp = 24.dp, + tint: Color = Color.Black, + filled: Boolean = false, +) { + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center, + ) { + Row { + Text( + text = "B", + fontFamily = FontFamily.SansSerif, + fontSize = (size.value * 0.65F).sp, + fontWeight = if (filled) FontWeight.W900 else FontWeight.W700, + color = if (filled) tint else tint.copy(alpha = 0.6F), + textDecoration = if (filled) TextDecoration.Underline else TextDecoration.None + ) + Text( + text = "R", + fontFamily = FontFamily.SansSerif, + fontSize = (size.value * 0.65F).sp, + fontWeight = FontWeight.W300, + color = if (filled) tint else tint.copy(alpha = 0.6F), + textDecoration = if (filled) TextDecoration.Underline else TextDecoration.None + ) + } + } +} + +@Preview(backgroundColor = 0xFFFFFF) +@Composable +private fun BionicReadingIconPreview() { + Column { + BionicReadingIcon() + BionicReadingIcon(filled = true) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/JavaScriptInterface.kt b/app/src/main/java/me/ash/reader/ui/component/webview/JavaScriptInterface.kt new file mode 100644 index 000000000..321df3df5 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/JavaScriptInterface.kt @@ -0,0 +1,14 @@ +package me.ash.reader.ui.component.webview + +import android.webkit.JavascriptInterface + +interface JavaScriptInterface { + + @JavascriptInterface + fun onImgTagClick(imgUrl: String?, alt: String?) + + companion object { + + const val NAME = "JavaScriptInterface" + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/RYWebView.kt b/app/src/main/java/me/ash/reader/ui/component/webview/RYWebView.kt new file mode 100644 index 000000000..08a197212 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/RYWebView.kt @@ -0,0 +1,124 @@ +package me.ash.reader.ui.component.webview + +import android.util.Log +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import me.ash.reader.infrastructure.preference.LocalOpenLink +import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser +import me.ash.reader.infrastructure.preference.LocalReadingBionicReading +import me.ash.reader.infrastructure.preference.LocalReadingImageHorizontalPadding +import me.ash.reader.infrastructure.preference.LocalReadingImageRoundedCorners +import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation +import me.ash.reader.infrastructure.preference.LocalReadingSubheadAlign +import me.ash.reader.infrastructure.preference.LocalReadingSubheadBold +import me.ash.reader.infrastructure.preference.LocalReadingTextAlign +import me.ash.reader.infrastructure.preference.LocalReadingTextBold +import me.ash.reader.infrastructure.preference.LocalReadingTextFontSize +import me.ash.reader.infrastructure.preference.LocalReadingTextHorizontalPadding +import me.ash.reader.infrastructure.preference.LocalReadingTextLetterSpacing +import me.ash.reader.infrastructure.preference.LocalReadingTextLineHeight +import me.ash.reader.ui.ext.openURL +import me.ash.reader.ui.ext.surfaceColorAtElevation +import me.ash.reader.ui.theme.palette.alwaysLight + +@Composable +fun RYWebView( + modifier: Modifier = Modifier, + content: String, + refererDomain: String? = null, + onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null, +) { + val context = LocalContext.current + val maxWidth = LocalConfiguration.current.screenWidthDp.dp.value + val openLink = LocalOpenLink.current + val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current + val tonalElevation = LocalReadingPageTonalElevation.current + val backgroundColor = MaterialTheme.colorScheme + .surfaceColorAtElevation(tonalElevation.value.dp).toArgb() + val selectionTextColor = Color.Black.toArgb() + val selectionBgColor = (MaterialTheme.colorScheme.tertiaryContainer alwaysLight true).toArgb() + val textColor: Int = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() + val boldTextColor: Int = MaterialTheme.colorScheme.onSurface.toArgb() + val linkTextColor: Int = MaterialTheme.colorScheme.primary.toArgb() + val subheadColor: Int = MaterialTheme.colorScheme.onSurface.toArgb() + val subheadBold: Boolean = LocalReadingSubheadBold.current.value + val subheadAlign: String = LocalReadingSubheadAlign.current.toTextAlignCSS() + val textBold: Boolean = LocalReadingTextBold.current.value + val textAlign: String = LocalReadingTextAlign.current.toTextAlignCSS() + val fontSize: Int = LocalReadingTextFontSize.current + val textLetterSpacing: Float = LocalReadingTextLetterSpacing.current + val lineHeight: Float = LocalReadingTextLineHeight.current + val textMargin: Int = 20 + val imgMargin: Int = LocalReadingImageHorizontalPadding.current + val imgBorderRadius: Int = LocalReadingImageRoundedCorners.current + val textHorizontalPadding: Int = LocalReadingTextHorizontalPadding.current + val imageShape: Int = LocalReadingImageRoundedCorners.current + val codeTextColor: Int = MaterialTheme.colorScheme.tertiary.toArgb() + val codeBgColor: Int = MaterialTheme.colorScheme + .surfaceColorAtElevation((tonalElevation.value + 6).dp).toArgb() + val bionicReading = LocalReadingBionicReading.current + + val webView by remember(backgroundColor) { + mutableStateOf( + WebViewLayout.get( + context = context, + webViewClient = WebViewClient( + context = context, + refererDomain = refererDomain, + onOpenLink = { url -> + context.openURL(url, openLink, openLinkSpecificBrowser) + } + ), + onImageClick = onImageClick + ) + ) + } + + AndroidView( + modifier = modifier, + factory = { webView }, + update = { + it.apply { + Log.i("RLog", "maxWidth: ${maxWidth}") + Log.i("RLog", "readingFont: ${context.filesDir.absolutePath}") + Log.i("RLog", "CustomWebView: ${content}") + settings.defaultFontSize = fontSize + loadDataWithBaseURL( + null, + WebViewHtml.HTML.format( + WebViewStyle.get( + fontSize = fontSize, + lineHeight = lineHeight, + textMargin = textMargin, + textColor = textColor, + boldTextColor = boldTextColor, + imgMargin = imgMargin, + imgBorderRadius = imgBorderRadius, + linkTextColor = linkTextColor, + codeTextColor = codeTextColor, + codeBgColor = codeBgColor, + tableMargin = textMargin, + selectionTextColor = selectionTextColor, + selectionBgColor = selectionBgColor, + ), + url, + content, + WebViewScript.get(bionicReading.value), + ), + "text/HTML", + "UTF-8", null + ) + } + }, + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewClient.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewClient.kt new file mode 100644 index 000000000..242d19c9f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewClient.kt @@ -0,0 +1,96 @@ +package me.ash.reader.ui.component.webview + +import android.content.Context +import android.net.http.SslError +import android.util.Log +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import me.ash.reader.ui.ext.isUrl +import java.io.DataInputStream +import java.net.HttpURLConnection +import java.net.URI + +const val INJECTION_TOKEN = "/android_asset_font/" + +class WebViewClient( + private val context: Context, + private val refererDomain: String?, + private val onOpenLink: (url: String) -> Unit, +) : WebViewClient() { + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + val url = request?.url?.toString() + if (url != null && url.contains(INJECTION_TOKEN)) { + try { + val assetPath = url.substring( + url.indexOf(INJECTION_TOKEN) + INJECTION_TOKEN.length, + url.length + ) + return WebResourceResponse( + "text/HTML", + "UTF-8", + context.assets.open(assetPath) + ) + } catch (e: Exception) { + Log.e("RLog", "WebView shouldInterceptRequest: $e") + } + } else if (url != null && url.isUrl()) { + try { + var connection = URI.create(url).toURL().openConnection() as HttpURLConnection + if (connection.responseCode == 403) { + connection.disconnect() + connection = URI.create(url).toURL().openConnection() as HttpURLConnection + connection.setRequestProperty("Referer", refererDomain) + val inputStream = DataInputStream(connection.inputStream) + return WebResourceResponse(connection.contentType, "UTF-8", inputStream) + } + } catch (e: Exception) { + Log.e("RLog", "shouldInterceptRequest url: $e") + } + } + return super.shouldInterceptRequest(view, request) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + val jsCode = """ + javascript:(function() { + var imgs = document.getElementsByTagName("img"); + for(var i = 0; i < imgs.length; i++){ + imgs[i].pos = i; + imgs[i].onclick = function() { + window.${JavaScriptInterface.NAME}.onImgTagClick(this.src, this.alt); + } + } + })() + """ + view!!.loadUrl(jsCode) + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (null == request?.url) return false + val url = request.url.toString() + if (url.isNotEmpty()) onOpenLink(url) + return true + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + Log.e("RLog", "RYWebView onReceivedError: $error") + } + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + handler?.cancel() + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewHtml.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewHtml.kt new file mode 100644 index 000000000..dd6207aad --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewHtml.kt @@ -0,0 +1,29 @@ +package me.ash.reader.ui.component.webview + +object WebViewHtml { + + const val HTML: String = """ + + + + + + + + + +
+ +
+ %s +
+
+ + + +""" +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewLayout.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewLayout.kt new file mode 100644 index 000000000..54ae40237 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewLayout.kt @@ -0,0 +1,39 @@ +package me.ash.reader.ui.component.webview + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.webkit.JavascriptInterface +import android.webkit.WebView + +object WebViewLayout { + + @SuppressLint("SetJavaScriptEnabled") + fun get( + context: Context, + webViewClient: WebViewClient, + onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null, + ) = WebView(context).apply { + this.webViewClient = webViewClient + scrollBarSize = 0 + isHorizontalScrollBarEnabled = false + isVerticalScrollBarEnabled = true + setBackgroundColor(Color.TRANSPARENT) + with(this.settings) { + domStorageEnabled = true + javaScriptEnabled = true + addJavascriptInterface(object : JavaScriptInterface { + @JavascriptInterface + override fun onImgTagClick(imgUrl: String?, alt: String?) { + if (onImageClick != null && imgUrl != null) { + onImageClick.invoke(imgUrl, alt ?: "") + } + } + }, JavaScriptInterface.NAME) + setSupportZoom(false) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + isAlgorithmicDarkeningAllowed = true + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewScript.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewScript.kt new file mode 100644 index 000000000..e4d67d07d --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewScript.kt @@ -0,0 +1,95 @@ +package me.ash.reader.ui.component.webview + +object WebViewScript { + + fun get(bionicReading: Boolean) = """ +function bionicRead() { + let div = document.body; + + // Check if the input is empty + if (!div) { + alert("The element with id 'readability-page-1' does not exist."); + return; + } + + // Remove all existing tags + let strongTags = div.querySelectorAll('strong'); + strongTags.forEach(tag => { + let parent = tag.parentNode; + while (tag.firstChild) { + parent.insertBefore(tag.firstChild, tag); + } + parent.removeChild(tag); + }); + + // Get all text nodes within the div, ignoring elements and their children + let walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + let parent = node.parentNode; + while (parent) { + if (parent.nodeName === 'CODE') { + return NodeFilter.FILTER_REJECT; + } + parent = parent.parentNode; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + + let textNodes = []; + while (walker.nextNode()) { + textNodes.push(walker.currentNode); + } + + // Regex to match emoji characters + const emojiRegex = /[\u{1F600}-\u{1F6FF}|\u{1F300}-\u{1F5FF}|\u{1F680}-\u{1F6FF}|\u{1F700}-\u{1F77F}|\u{1F780}-\u{1F7FF}|\u{1F800}-\u{1F8FF}|\u{1F900}-\u{1F9FF}|\u{1FA00}-\u{1FA6F}|\u{1FA70}-\u{1FAFF}|\u{2600}-\u{26FF}|\u{2700}-\u{27BF}|\u{1F1E0}-\u{1F1FF}]/u; + + // Process each text node + textNodes.forEach(node => { + let text = node.textContent; + + // Split text into words and process each word + let words = text.split(/(\s+)/); // Keep spaces in the split + let formattedText = ""; + words.forEach(word => { + if (word.trim() && !emojiRegex.test(word)) { + let halfIndex = Math.round(word.length / 2); + let half = word.substr(0, halfIndex); + let remHalf = word.substr(halfIndex); + formattedText += "" + half + "" + remHalf; + } else { + formattedText += word; // Preserve spaces and skip emoji + } + }); + + // Create a temporary div to parse HTML + let tempDiv = document.createElement('div'); + tempDiv.innerHTML = formattedText; + + // Replace original text node with new HTML content + while (tempDiv.firstChild) { + node.parentNode.insertBefore(tempDiv.firstChild, node); + } + node.parentNode.removeChild(node); + }); +} + +${if (bionicReading) "bionicRead()" else ""} + +var images = document.querySelectorAll("img"); + +images.forEach(function(img) { + img.onload = function() { + img.classList.add("loaded"); + console.log("Image width:", img.width, "px"); + if (img.width < 412) { + img.classList.add("thin"); + } + }; + + img.onerror = function() { + console.error("Failed to load image:", img.src); + }; +}); +""" +} diff --git a/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt new file mode 100644 index 000000000..b8bad345f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/webview/WebViewStyle.kt @@ -0,0 +1,298 @@ +package me.ash.reader.ui.component.webview + +object WebViewStyle { + + private fun argbToCssColor(argb: Int): String = String.format("#%06X", 0xFFFFFF and argb) + + fun get( + fontSize: Int, + lineHeight: Float, + textMargin: Int, + textColor: Int, + boldTextColor: Int, + imgMargin: Int, + imgBorderRadius: Int, + linkTextColor: Int, + codeTextColor: Int, + codeBgColor: Int, + tableMargin: Int, + selectionTextColor: Int, + selectionBgColor: Int, + ): String = """ +:root { + /* --font-family: Inter; */ + --font-size: ${fontSize}px; + --line-height: ${lineHeight}; + --text-margin: ${textMargin}px; + --text-color: ${argbToCssColor(textColor)}; + --bold-text-color: ${argbToCssColor(boldTextColor)}; + --link-text-color: ${argbToCssColor(linkTextColor)}; + --selection-text-color: ${argbToCssColor(selectionTextColor)}; + --selection-bg-color: ${argbToCssColor(selectionBgColor)}; + --img-margin: ${imgMargin}px; + --img-border-radius: ${imgBorderRadius}px; + --content-padding; + --bold-text-color; + --image-caption-margin; + --blockquote-margin: 20px; + --blockquote-padding; + --blockquote-bg-color; + --blockquote-border-width: 3px; + --blockquote-border-color: ${argbToCssColor(textColor)}33; + --table-margin: ${tableMargin}px; + --table-border-width; + --table-border-color; + --table-cell-padding: 0.2em; + --table-alt-row-bg-color; + --code-text-color: ${argbToCssColor(codeTextColor)}; + --code-bg-color: ${argbToCssColor(codeBgColor)}; + --code-scrollbar-color: ${argbToCssColor(codeTextColor)}22; + --code-border-width; + --code-border-color; + --code-padding; + --code-font-family: Menlo, Monospace, 'Courier New'; + --code-font-size: 0.9em; + --pre-color; +} + +article { + padding: 0; + margin: 0; + margin-left: var(--text-margin) !important; + margin-right: var(--text-margin) !important; + font-family: var(--font-family) !important; + font-size: var(--font-size) !important; + color: var(--text-color) !important; +} + +/* Page */ +body { + margin: 0; + padding 0; +} + +::selection { + background-color: var(--selection-bg-color) !important; + color: var(--selection-text-color) !important; +} + +/* Heading */ +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: calc(min(1.2, var(--line-height))) !important; + font-weight: 600 !important; + color: var(--bold-text-color) !important; +} + +/* Paragraph */ +p { + max-width: 100% !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + line-height: var(--line-height) !important; +} + +span { + line-height: var(--line-height) !important; +} + +/* Strong */ +strong, +b { + font-weight: 600 !important; + color: var(--bold-text-color) !important; +} + +/* Link */ +a, +a > strong { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-weight: 600 !important; + color: var(--link-text-color) !important; +} +div > a { + display: block; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-weight: 600 !important; + color: var(--link-text-color); + line-height: var(--line-height); +} + +/* Image */ +img { + margin-top: 0.5em !important; + margin-left: calc(0px - var(--text-margin) + var(--img-margin)) !important; + margin-right: calc(0px - var(--text-margin) + var(--img-margin)) !important; + max-width: calc(100% + 2 * var(--text-margin) - 2 * var(--img-margin)) !important; + height: auto !important; + border-radius: var(--img-border-radius) !important; +} + +img::after { + width: 100px !important; +} + +img.loaded { + opacity: 1; /* 加载完成后设置透明度为1 */ +} + +img.thin { + margin-top: 0.5em !important; + margin-bottom: 0.5em !important; + margin-left: unset !important; + margin-right: unset !important; + max-width: 100% !important; +} + +p > img { + margin-top: 0.5em !important; + margin-bottom: 0.5em !important; + margin-left: calc(0px - var(--text-margin) + var(--img-margin)) !important; + margin-right: calc(0px - var(--text-margin) + var(--img-margin)) !important; + max-width: calc(100% + 2 * var(--text-margin) - 2 * var(--img-margin)) !important; + height: auto !important; + border-radius: var(--img-border-radius) !important; +} + +img + small { + display: inline-block; + line-height: calc(min(1.5, var(--line-height))) !important; + margin-top: var(--image-caption-margin) !important; +} + +/* List */ +ul, +ol { + padding-left: 0 !important; + line-height: var(--line-height) !important; +} + +li { + line-height: var(--line-height) !important; + margin-left: 1.5em !important; +} + +/* Quote */ +blockquote { + margin-left: 0.5em !important; + padding-left: calc(0.9em) !important; + background-color: var(--blockquote-bg-color) !important; + border-left: var(--blockquote-border-width) solid var(--blockquote-border-color) !important; + line-height: var(--line-height) !important; +} + +blockquote blockquote { + margin-right: 0 !important; +} + +blockquote img { + max-width 100% !important; + left: 0 !important; +} + +/* Table */ +table { + display: block; + max-width: var(--content-width) !important; + width: 100% !important; + border-collapse: collapse !important; + margin-left: var(--table-margin) !important; + margin-right: var(--table-margin) !important; +} + +table th, +table td { + border: var(--table-border-width) solid var(--table-border-color) !important; + padding: var(--table-cell-padding) !important; + line-height: var(--line-height) !important; +} + +table tr { + display: block; +} + +table tr table tr td { + display: inline-block; +} + +table tr:nth-child(even) { + background-color: var(--table-alt-row-bg-color) !important; +} + +/* Code */ +pre, +code { + color: var(--code-text-color) !important; + background-color: var(--code-bg-color) !important; + border: 1 solid var(--code-text-color) !important; + border-radius: 8px !important; + padding: 2px 5px !important; + margin: 2px !important; + font-family: var(--code-font-family) !important; + font-size: var(--code-font-size) !important; +} + +pre { + overflow: auto !important; +} + +code { + display: inline-block !important; +} + +li code { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + max-width: 100% !important; +} + +pre::-webkit-scrollbar { + height: 14px; +} + +pre::-webkit-scrollbar-track { + background-color: transparent; +} + +pre::-webkit-scrollbar-thumb { + background-color: var(--code-scrollbar-color); + border-radius: 7px; + background-clip: content-box; + border: 5px solid transparent; + border-left-width: 10px; + border-right-width: 10px; +} + +/* MISC */ +figure { + line-height: calc(min(1.5, var(--line-height))) !important; + margin: 0 !important; + opacity: 0.8 !important; + font-size: 0.8em !important; +} + +figure * { + font-size: 1em !important; +} + +figure p, +caption, +figcaption { + opacity: 0.8 !important; + font-size: 0.8em !important; +} + +hr { + border: 0 !important; + height: 2px !important; + background-color: var(--text-color) !important; + opacity: 0.08 !important; + border-radius: 2px; +} +""" +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index dddadc5c3..c8d26873d 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -135,6 +135,7 @@ data class DataStoreKey( // Reading page const val readingRenderer = "readingRender" + const val readingBionicReading = "readingBionicReading" const val readingDarkTheme = "readingDarkTheme" const val readingPageTonalElevation = "readingPageTonalElevation" const val readingTextFontSize = "readingTextFontSize" @@ -209,6 +210,7 @@ data class DataStoreKey( flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java), // Reading page readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java), + readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java), readingDarkTheme to DataStoreKey(intPreferencesKey(readingDarkTheme), Int::class.java), readingPageTonalElevation to DataStoreKey(intPreferencesKey(readingPageTonalElevation), Int::class.java), readingTextFontSize to DataStoreKey(intPreferencesKey(readingTextFontSize), Int::class.java), diff --git a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt index 6d4dec806..af77a5efa 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt @@ -1,7 +1,6 @@ package me.ash.reader.ui.page.common import android.util.Log -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -22,9 +21,9 @@ import kotlinx.coroutines.flow.collectLatest import me.ash.reader.domain.model.general.Filter import me.ash.reader.infrastructure.preference.LocalDarkTheme import me.ash.reader.infrastructure.preference.LocalReadingDarkTheme +import me.ash.reader.ui.ext.animatedComposable import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.findActivity -import me.ash.reader.ui.ext.animatedComposable import me.ash.reader.ui.ext.initialFilter import me.ash.reader.ui.ext.initialPage import me.ash.reader.ui.ext.isFirstLaunch @@ -41,6 +40,7 @@ import me.ash.reader.ui.page.settings.color.ColorAndStylePage import me.ash.reader.ui.page.settings.color.DarkThemePage import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage import me.ash.reader.ui.page.settings.color.flow.FlowPageStylePage +import me.ash.reader.ui.page.settings.color.reading.BionicReadingPage import me.ash.reader.ui.page.settings.color.reading.ReadingDarkThemePage import me.ash.reader.ui.page.settings.color.reading.ReadingImagePage import me.ash.reader.ui.page.settings.color.reading.ReadingStylePage @@ -207,6 +207,9 @@ fun HomeEntry( animatedComposable(route = RouteName.READING_PAGE_STYLE) { ReadingStylePage(navController) } + animatedComposable(route = RouteName.READING_BIONIC_READING) { + BionicReadingPage(navController) + } animatedComposable(route = RouteName.READING_DARK_THEME) { ReadingDarkThemePage(navController) } diff --git a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt index 5897ac3e5..cb7b128be 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt @@ -24,6 +24,7 @@ object RouteName { const val FEEDS_PAGE_STYLE = "feeds_page_style" const val FLOW_PAGE_STYLE = "flow_page_style" const val READING_PAGE_STYLE = "reading_page_style" + const val READING_BIONIC_READING = "reading_bionic_reading" const val READING_DARK_THEME = "reading_dark_theme" const val READING_PAGE_TITLE = "reading_page_title" const val READING_PAGE_TEXT = "reading_page_text" diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt index ed3c1e202..78b3be029 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/BottomBar.kt @@ -1,15 +1,20 @@ package me.ash.reader.ui.page.home.reading import android.view.HapticFeedbackConstants -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.rounded.Article import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.outlined.Article import androidx.compose.material.icons.outlined.FiberManualRecord import androidx.compose.material.icons.outlined.Headphones -import androidx.compose.material.icons.rounded.Article import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.StarOutline @@ -24,8 +29,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation +import me.ash.reader.infrastructure.preference.LocalReadingRenderer +import me.ash.reader.infrastructure.preference.ReadingRendererPreference import me.ash.reader.ui.component.base.CanBeDisabledIconButton import me.ash.reader.ui.component.base.RYExtensibleVisibility +import me.ash.reader.ui.component.webview.BionicReadingIcon @Composable fun BottomBar( @@ -34,12 +42,16 @@ fun BottomBar( isStarred: Boolean, isNextArticleAvailable: Boolean, isFullContent: Boolean, + isBionicReading: Boolean, onUnread: (isUnread: Boolean) -> Unit = {}, onStarred: (isStarred: Boolean) -> Unit = {}, onNextArticle: () -> Unit = {}, onFullContent: (isFullContent: Boolean) -> Unit = {}, + onBionicReading: () -> Unit = {}, + onReadAloud: () -> Unit = {}, ) { val tonalElevation = LocalReadingPageTonalElevation.current + val renderer = LocalReadingRenderer.current Box( modifier = Modifier @@ -110,12 +122,32 @@ fun BottomBar( } CanBeDisabledIconButton( modifier = Modifier.size(36.dp), - disabled = true, - imageVector = Icons.Outlined.Headphones, - contentDescription = "Add Tag", + disabled = false, + imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones, + contentDescription = if (renderer == ReadingRendererPreference.WebView) { + stringResource(R.string.bionic_reading) + } else { + stringResource(R.string.read_aloud) + }, tint = MaterialTheme.colorScheme.outline, + icon = { + BionicReadingIcon( + filled = isBionicReading, + size = 24.dp, + tint = if (renderer == ReadingRendererPreference.WebView) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.outline + } + ) + }, ) { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + if (renderer == ReadingRendererPreference.WebView) { + onBionicReading() + } else { + onReadAloud() + } } CanBeDisabledIconButton( disabled = false, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt index 101e0858e..1c75b56fa 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt @@ -24,9 +24,10 @@ import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser import me.ash.reader.infrastructure.preference.LocalReadingRenderer import me.ash.reader.infrastructure.preference.LocalReadingSubheadUpperCase import me.ash.reader.infrastructure.preference.ReadingRendererPreference -import me.ash.reader.ui.component.base.RYWebView import me.ash.reader.ui.component.reader.Reader +import me.ash.reader.ui.component.webview.RYWebView import me.ash.reader.ui.ext.drawVerticalScrollbar +import me.ash.reader.ui.ext.extractDomain import me.ash.reader.ui.ext.openURL import java.util.Date @@ -85,6 +86,7 @@ fun Content( ) } } + Spacer(modifier = Modifier.height(22.dp)) } when (renderer) { @@ -92,9 +94,8 @@ fun Content( item { RYWebView( content = content, - onReceivedError = { - // throw RuntimeException("errorCode: ${it?.errorCode}, description: ${it?.description}") - } + refererDomain = link.extractDomain(), + onImageClick = onImageClick, ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 7d2ea14cc..5ac4040ba 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -24,25 +24,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import androidx.paging.compose.collectAsLazyPagingItems import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar -import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation +import me.ash.reader.infrastructure.preference.LocalReadingBionicReading import me.ash.reader.infrastructure.preference.LocalReadingTextLineHeight +import me.ash.reader.infrastructure.preference.not import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.showToast import me.ash.reader.ui.motion.materialSharedAxisY import me.ash.reader.ui.page.home.HomeViewModel import kotlin.math.abs - private const val UPWARD = 1 private const val DOWNWARD = -1 @@ -55,12 +55,12 @@ fun ReadingPage( homeViewModel: HomeViewModel, readingViewModel: ReadingViewModel = hiltViewModel(), ) { - val tonalElevation = LocalReadingPageTonalElevation.current val context = LocalContext.current val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue() val homeUiState = homeViewModel.homeUiState.collectAsStateValue() + val bionicReading = LocalReadingBionicReading.current var isReaderScrollingDown by remember { mutableStateOf(false) } var showFullScreenImageViewer by remember { mutableStateOf(false) } @@ -217,6 +217,7 @@ fun ReadingPage( isStarred = readingUiState.isStarred, isNextArticleAvailable = isNextArticleAvailable, isFullContent = readerState.content is ReaderState.FullContent, + isBionicReading = bionicReading.value, onUnread = { readingViewModel.updateReadStatus(it) }, @@ -230,6 +231,12 @@ fun ReadingPage( if (it) readingViewModel.renderFullContent() else readingViewModel.renderDescriptionContent() }, + onBionicReading = { + (!bionicReading).put(context, homeViewModel.viewModelScope) + }, + onReadAloud = { + context.showToast(context.getString(R.string.coming_soon)) + } ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt index 12063f0c4..079ab7193 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt @@ -9,8 +9,17 @@ package me.ash.reader.ui.page.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,7 +94,7 @@ fun SettingItem( } action?.let { if (separatedActions) { - HorizontalDivider( + VerticalDivider( modifier = Modifier .padding(start = 16.dp) .size(1.dp, 32.dp), diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/BionicReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/BionicReadingPage.kt new file mode 100644 index 000000000..ed30312b3 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/BionicReadingPage.kt @@ -0,0 +1,140 @@ +package me.ash.reader.ui.page.settings.color.reading + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.ireward.htmlcompose.HtmlText +import me.ash.reader.R +import me.ash.reader.infrastructure.preference.LocalOpenLink +import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser +import me.ash.reader.infrastructure.preference.LocalReadingBionicReading +import me.ash.reader.infrastructure.preference.not +import me.ash.reader.ui.component.base.Banner +import me.ash.reader.ui.component.base.DisplayText +import me.ash.reader.ui.component.base.FeedbackIconButton +import me.ash.reader.ui.component.base.RYScaffold +import me.ash.reader.ui.component.base.RYSwitch +import me.ash.reader.ui.component.base.Subtitle +import me.ash.reader.ui.component.base.Tips +import me.ash.reader.ui.component.webview.RYWebView +import me.ash.reader.ui.ext.openURL +import me.ash.reader.ui.theme.palette.onLight + +@Composable +fun BionicReadingPage( + navController: NavHostController, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val openLink = LocalOpenLink.current + val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current + + val bionicReading = LocalReadingBionicReading.current + + RYScaffold( + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.popBackStack() + } + }, + content = { + LazyColumn { + item { + DisplayText(text = stringResource(R.string.bionic_reading), desc = "") + } + + // Preview + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .clip(RoundedCornerShape(24.dp)) + .background( + MaterialTheme.colorScheme.inverseOnSurface + onLight MaterialTheme.colorScheme.surface.copy(0.7f) + ) + .clickable { }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + RYWebView( + content = stringResource(R.string.bionic_reading_preview), + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + item { + Banner( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(R.string.use_bionic_reading), + action = { + RYSwitch(activated = bionicReading.value) { + (!bionicReading).put(context, scope) + } + }, + ) { + (!bionicReading).put(context, scope) + } + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.about) + ) + Tips( + text = stringResource(R.string.bionic_reading_tips), + ) + TextButton( + modifier = Modifier.padding(horizontal = 12.dp), + onClick = { + context.openURL(context.getString(R.string.bionic_reading_link), openLink, openLinkSpecificBrowser) + } + ) { + HtmlText( + text = stringResource(R.string.browse_bionic_reading_tips), + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline, + ), + ) + } + } + + item { + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } + } + } + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt index 93518e10d..4feb92f48 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt @@ -42,6 +42,7 @@ import androidx.navigation.NavHostController import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar +import me.ash.reader.infrastructure.preference.LocalReadingBionicReading import me.ash.reader.infrastructure.preference.LocalReadingDarkTheme import me.ash.reader.infrastructure.preference.LocalReadingFonts import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation @@ -82,7 +83,7 @@ fun ReadingStylePage( val autoHideToolbar = LocalReadingAutoHideToolbar.current val pullToSwitchArticle = LocalPullToSwitchArticle.current val renderer = LocalReadingRenderer.current - + val bionicReading = LocalReadingBionicReading.current var tonalElevationDialogVisible by remember { mutableStateOf(false) } var rendererDialogVisible by remember { mutableStateOf(false) } @@ -160,6 +161,27 @@ fun ReadingStylePage( desc = renderer.toDesc(context), onClick = { rendererDialogVisible = true }, ) {} + SettingItem( + title = stringResource(R.string.bionic_reading), + separatedActions = renderer == ReadingRendererPreference.WebView, + enabled = renderer == ReadingRendererPreference.WebView, + desc = if (renderer == ReadingRendererPreference.WebView) null + else stringResource(R.string.only_available_on_webview), + onClick = { + navController.navigate(RouteName.READING_BIONIC_READING) { + launchSingleTop = true + } + }, + ) { + if (renderer == ReadingRendererPreference.WebView) { + RYSwitch( + enable = renderer == ReadingRendererPreference.WebView, + activated = bionicReading.value, + ) { + (!bionicReading).put(context, scope) + } + } + } SettingItem( title = stringResource(R.string.reading_fonts), desc = fonts.toDesc(context), @@ -181,21 +203,6 @@ fun ReadingStylePage( darkThemeNot.put(context, scope) } } - SettingItem( - title = stringResource(R.string.bionic_reading), - separatedActions = true, - enabled = false, - onClick = { -// (!articleListDesc).put(context, scope) - }, - ) { - RYSwitch( - activated = false, - enable = false, - ) { -// (!articleListDesc).put(context, scope) - } - } SettingItem( title = stringResource(R.string.auto_hide_toolbars), onClick = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0af108c3a..76c3ab177 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,7 +445,15 @@ Import from JSON Export as JSON This file may not be a valid JSON file. Importing it could potentially corrupt the app and result in the loss of current preferences. Are you sure you want to proceed? - Webview + WebView Native Component Content renderer + Read Aloud + Only available on the WebView + With Bionic Reading you read texts faster, better and more focused.

]]> + Use Bionic Reading + About + What is Bionic Reading? + Learn more at <i><u>bionic-reading.com</u></i>. + https://bionic-reading.com