diff --git a/README-zh.md b/README-zh.md index 98ddf09..5581a59 100644 --- a/README-zh.md +++ b/README-zh.md @@ -90,7 +90,8 @@ - [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE) - [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE) - [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt) -- (待完善) +- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE) +- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE) ## 许可证 diff --git a/README.md b/README.md index 944156d..d1c0dc7 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ The following are the progress made so far and the goals to be worked on in the - [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE) - [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE) - [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt) -- (To be improved) +- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE) +- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE) ## License diff --git a/app/build.gradle b/app/build.gradle index 585743f..d4d4bb5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,6 +101,7 @@ dependencies { // https://coil-kt.github.io/coil/changelog/ implementation("io.coil-kt:coil-compose:$coil") implementation("io.coil-kt:coil-svg:$coil") + implementation("io.coil-kt:coil-gif:$coil") // https://square.github.io/okhttp/changelogs/changelog/ implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.6" diff --git a/app/src/main/java/me/ash/reader/App.kt b/app/src/main/java/me/ash/reader/App.kt index def3ca0..bf7817d 100644 --- a/app/src/main/java/me/ash/reader/App.kt +++ b/app/src/main/java/me/ash/reader/App.kt @@ -1,10 +1,24 @@ package me.ash.reader import android.app.Application +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.os.Build import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import androidx.work.WorkManager +import coil.ComponentRegistry +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.decode.SvgDecoder +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.* import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -18,7 +32,7 @@ import me.ash.reader.ui.ext.* import javax.inject.Inject @HiltAndroidApp -class App : Application(), Configuration.Provider { +class App : Application(), Configuration.Provider, ImageLoader { @Inject lateinit var readerDatabase: ReaderDatabase @@ -108,4 +122,58 @@ class App : Application(), Configuration.Provider { .setWorkerFactory(workerFactory) .setMinimumLoggingLevel(android.util.Log.DEBUG) .build() + + override val components: ComponentRegistry + get() = ComponentRegistry.Builder() + .add(SvgDecoder.Factory()) + .add( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoderDecoder.Factory() + } else { + GifDecoder.Factory() + } + ) + .build() + override val defaults: DefaultRequestOptions + get() = DefaultRequestOptions() + override val diskCache: DiskCache + get() = DiskCache.Builder() + .directory(cacheDir.resolve("images")) + .maxSizePercent(0.02) + .build() + override val memoryCache: MemoryCache + get() = MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + + override fun enqueue(request: ImageRequest): Disposable { + // Always call onStart before onSuccess. + request.target?.onStart(request.placeholder) + val result = ColorDrawable(Color.BLACK) + request.target?.onSuccess(result) + return object : Disposable { + override val job = CompletableDeferred(newResult(request, result)) + override val isDisposed get() = true + override fun dispose() {} + } + } + + override suspend fun execute(request: ImageRequest): ImageResult { + return newResult(request, ColorDrawable(Color.BLACK)) + } + + override fun newBuilder(): ImageLoader.Builder { + throw UnsupportedOperationException() + } + + override fun shutdown() { + } + + private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult { + return SuccessResult( + drawable = drawable, + request = request, + dataSource = DataSource.MEMORY_CACHE + ) + } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/MainActivity.kt b/app/src/main/java/me/ash/reader/MainActivity.kt index b4f2ebe..cc9c75f 100644 --- a/app/src/main/java/me/ash/reader/MainActivity.kt +++ b/app/src/main/java/me/ash/reader/MainActivity.kt @@ -1,30 +1,19 @@ package me.ash.reader -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat import androidx.profileinstaller.ProfileInstallerInitializer -import coil.ComponentRegistry -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.SvgDecoder -import coil.disk.DiskCache -import coil.memory.MemoryCache -import coil.request.* import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CompletableDeferred import me.ash.reader.data.preference.LanguagesPreference import me.ash.reader.data.preference.SettingsProvider import me.ash.reader.ui.ext.languages import me.ash.reader.ui.page.common.HomeEntry @AndroidEntryPoint -class MainActivity : ComponentActivity(), ImageLoader { +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -43,49 +32,4 @@ class MainActivity : ComponentActivity(), ImageLoader { } } } - - override val components: ComponentRegistry - get() = ComponentRegistry.Builder().add(SvgDecoder.Factory()).build() - override val defaults: DefaultRequestOptions - get() = DefaultRequestOptions() - override val diskCache: DiskCache - get() = DiskCache.Builder() - .directory(this.cacheDir.resolve("images")) - .maxSizePercent(0.02) - .build() - override val memoryCache: MemoryCache - get() = MemoryCache.Builder(this) - .maxSizePercent(0.25) - .build() - - override fun enqueue(request: ImageRequest): Disposable { - // Always call onStart before onSuccess. - request.target?.onStart(request.placeholder) - val result = ColorDrawable(Color.BLACK) - request.target?.onSuccess(result) - return object : Disposable { - override val job = CompletableDeferred(newResult(request, result)) - override val isDisposed get() = true - override fun dispose() {} - } - } - - override suspend fun execute(request: ImageRequest): ImageResult { - return newResult(request, ColorDrawable(Color.BLACK)) - } - - override fun newBuilder(): ImageLoader.Builder { - throw UnsupportedOperationException() - } - - override fun shutdown() { - } - - private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult { - return SuccessResult( - drawable = drawable, - request = request, - dataSource = DataSource.MEMORY_CACHE - ) - } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/AsyncImage.kt b/app/src/main/java/me/ash/reader/ui/component/AsyncImage.kt new file mode 100644 index 0000000..1e06009 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/AsyncImage.kt @@ -0,0 +1,123 @@ +package me.ash.reader.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.imageLoader +import coil.request.ImageRequest +import coil.size.Precision +import coil.size.Scale +import coil.size.Size +import me.ash.reader.R + +@Composable +fun AsyncImage( + modifier: Modifier = Modifier, + data: Any? = null, + size: Size = Size.ORIGINAL, + scale: Scale = Scale.FIT, + precision: Precision = Precision.AUTOMATIC, + contentScale: ContentScale = ContentScale.Fit, + contentDescription: String = "", + @DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp, + @DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp, +) { + val context = LocalContext.current + + coil.compose.AsyncImage( + modifier = modifier, + model = ImageRequest + .Builder(context) + .data(data) + .crossfade(true) + .scale(scale) + .precision(precision) + .size(size) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + imageLoader = context.imageLoader, + placeholder = placeholder?.let { + forwardingPainter( + painter = painterResource(it), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + alpha = 0.5f, + ) + }, + error = error?.let { + forwardingPainter( + painter = painterResource(it), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onError), + alpha = 0.5f + ) + }, + ) +} + +// From: https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1 + +/** + * Create and return a new [Painter] that wraps [painter] with its [alpha], [colorFilter], or [onDraw] overwritten. + */ +fun forwardingPainter( + painter: Painter, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw, +): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw) + +data class ForwardingDrawInfo( + val painter: Painter, + val alpha: Float, + val colorFilter: ColorFilter?, +) + +private class ForwardingPainter( + private val painter: Painter, + private var alpha: Float, + private var colorFilter: ColorFilter?, + private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit, +) : Painter() { + + private var info = newInfo() + + override val intrinsicSize get() = painter.intrinsicSize + + override fun applyAlpha(alpha: Float): Boolean { + if (alpha == DefaultAlpha) { + this.alpha = alpha + this.info = newInfo() + } + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + if (colorFilter == null) { + this.colorFilter = colorFilter + this.info = newInfo() + } + return true + } + + override fun DrawScope.onDraw() = onDraw(info) + + private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter) +} + +private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info -> + with(info.painter) { + draw( + androidx.compose.ui.geometry.Size(size.width, size.height), + info.alpha, + info.colorFilter + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/DynamicSVGImage.kt b/app/src/main/java/me/ash/reader/ui/component/DynamicSVGImage.kt index 3e505f5..5b93670 100644 --- a/app/src/main/java/me/ash/reader/ui/component/DynamicSVGImage.kt +++ b/app/src/main/java/me/ash/reader/ui/component/DynamicSVGImage.kt @@ -7,11 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize -import coil.compose.AsyncImage -import coil.imageLoader -import coil.request.ImageRequest import com.caverock.androidsvg.SVG import me.ash.reader.data.preference.LocalDarkTheme import me.ash.reader.ui.svg.parseDynamicColor @@ -23,7 +19,6 @@ fun DynamicSVGImage( svgImageString: String, contentDescription: String, ) { - val context = LocalContext.current val useDarkTheme = LocalDarkTheme.current.isDarkTheme() val tonalPalettes = LocalTonalPalettes.current var size by remember { mutableStateOf(IntSize.Zero) } @@ -48,11 +43,9 @@ fun DynamicSVGImage( Crossfade(targetState = pic) { AsyncImage( contentDescription = contentDescription, - model = ImageRequest.Builder(context) - .data(it) - .crossfade(true) - .build(), - imageLoader = context.imageLoader, + data = it, + placeholder = null, + error = null, ) } } diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/AnnotatedString.kt b/app/src/main/java/me/ash/reader/ui/component/reader/AnnotatedString.kt new file mode 100644 index 0000000..dbadc3e --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/AnnotatedString.kt @@ -0,0 +1,191 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle + +class AnnotatedParagraphStringBuilder { + // Private for a reason + private val builder: AnnotatedString.Builder = AnnotatedString.Builder() + + private val poppedComposableStyles = mutableListOf() + val composableStyles = mutableListOf() + val lastTwoChars: MutableList = mutableListOf() + + val length: Int + get() = builder.length + + val endsWithWhitespace: Boolean + get() { + if (lastTwoChars.isEmpty()) { + return true + } + lastTwoChars.peekLatest()?.let { latest -> + // Non-breaking space (160) is not caught by trim or whitespace identification + if (latest.isWhitespace() || latest.code == 160) { + return true + } + } + + return false + } + + fun pushStyle(style: SpanStyle): Int = + builder.pushStyle(style = style) + + fun pop(index: Int) = + builder.pop(index) + + fun pushStringAnnotation(tag: String, annotation: String): Int = + builder.pushStringAnnotation(tag = tag, annotation = annotation) + + fun pushComposableStyle( + style: @Composable () -> SpanStyle + ): Int { + composableStyles.add( + ComposableStyleWithStartEnd( + style = style, + start = builder.length + ) + ) + return composableStyles.lastIndex + } + + fun popComposableStyle( + index: Int + ) { + poppedComposableStyles.add( + composableStyles.removeAt(index).copy(end = builder.length) + ) + } + + fun append(text: String) { + if (text.count() >= 2) { + lastTwoChars.pushMaxTwo(text.secondToLast()) + } + if (text.isNotEmpty()) { + lastTwoChars.pushMaxTwo(text.last()) + } + builder.append(text) + } + + fun append(char: Char) { + lastTwoChars.pushMaxTwo(char) + builder.append(char) + } + + @Composable + fun toAnnotatedString(): AnnotatedString { + for (composableStyle in poppedComposableStyles) { + builder.addStyle( + style = composableStyle.style(), + start = composableStyle.start, + end = composableStyle.end + ) + } + for (composableStyle in composableStyles) { + builder.addStyle( + style = composableStyle.style(), + start = composableStyle.start, + end = builder.length + ) + } + return builder.toAnnotatedString() + } +} + +fun AnnotatedParagraphStringBuilder.isEmpty() = lastTwoChars.isEmpty() +fun AnnotatedParagraphStringBuilder.isNotEmpty() = lastTwoChars.isNotEmpty() + +fun AnnotatedParagraphStringBuilder.ensureDoubleNewline() { + when { + lastTwoChars.isEmpty() -> { + // Nothing to do + } + length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> { + // Nothing to do + } + length == 2 && + lastTwoChars.peekLatest()?.isWhitespace() == true && + lastTwoChars.peekSecondLatest()?.isWhitespace() == true -> { + // Nothing to do + } + lastTwoChars.peekLatest() == '\n' && lastTwoChars.peekSecondLatest() == '\n' -> { + // Nothing to do + } + lastTwoChars.peekLatest() == '\n' -> { + append('\n') + } + else -> { + append("\n\n") + } + } +} + +private fun AnnotatedParagraphStringBuilder.ensureSingleNewline() { + when { + lastTwoChars.isEmpty() -> { + // Nothing to do + } + length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> { + // Nothing to do + } + lastTwoChars.peekLatest() == '\n' -> { + // Nothing to do + } + else -> { + append('\n') + } + } +} + +private fun CharSequence.secondToLast(): Char { + if (count() < 2) { + throw NoSuchElementException("List has less than two items.") + } + return this[lastIndex - 1] +} + +private fun MutableList.pushMaxTwo(item: T) { + this.add(0, item) + if (count() > 2) { + this.removeLast() + } +} + +private fun List.peekLatest(): T? { + return this.firstOrNull() +} + +private fun List.peekSecondLatest(): T? { + if (count() < 2) { + return null + } + return this[1] +} + +data class ComposableStyleWithStartEnd( + val style: @Composable () -> SpanStyle, + val start: Int, + val end: Int = -1 +) diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/ClickableTextWithInlineContent.kt b/app/src/main/java/me/ash/reader/ui/component/reader/ClickableTextWithInlineContent.kt new file mode 100644 index 0000000..0a0962b --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/ClickableTextWithInlineContent.kt @@ -0,0 +1,101 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow + +/** + * A continent version of [BasicText] component to be able to handle click event on the text. + * + * This is a shorthand of [BasicText] with [pointerInput] to be able to handle click + * event easily. + * + * @sample androidx.compose.foundation.samples.ClickableText + * + * For other gestures, e.g. long press, dragging, follow sample code. + * + * @sample androidx.compose.foundation.samples.LongClickableText + * + * @see BasicText + * @see androidx.compose.ui.input.pointer.pointerInput + * @see androidx.compose.foundation.gestures.detectTapGestures + * + * @param text The text to be displayed. + * @param modifier Modifier to apply to this layout node. + * @param style Style configuration for the text such as color, font, line height etc. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [overflow] and [TextAlign] may have unexpected effects. + * @param overflow How visual overflow should be handled. + * @param maxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [overflow] and [softWrap]. If it is not null, then it must be greater than zero. + * @param onTextLayout Callback that is executed when a new text layout is calculated. + * @param onClick Callback that is executed when users click the text. This callback is called + * with clicked character's offset. + */ +@Composable +fun ClickableTextWithInlineContent( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + inlineContent: Map = emptyMap(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + onClick: (Int) -> Unit, +) { + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(onClick) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> + onClick(layoutResult.getOffsetForPosition(pos)) + } + } + } + + BasicText( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = { + layoutResult.value = it + onTextLayout(it) + }, + inlineContent = inlineContent + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Extensions.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Extensions.kt new file mode 100644 index 0000000..0364b24 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/Extensions.kt @@ -0,0 +1,73 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import android.content.res.Resources +import android.text.Annotation +import android.text.SpannedString +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.core.text.getSpans + +@Composable +@ReadOnlyComposable +fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} + +@Composable +fun annotatedStringResource(@StringRes id: Int): AnnotatedString { + val resources = resources() + val text = resources.getText(id) as SpannedString + + return buildAnnotatedString { + this.append(text.toString()) + + for (annotation in text.getSpans()) { + when (annotation.key) { + "style" -> { + getSpanStyle(annotation.value)?.let { spanStyle -> + addStyle( + spanStyle, + text.getSpanStart(annotation), + text.getSpanEnd(annotation) + ) + } + } + } + } + } +} + +@Composable +private fun getSpanStyle(name: String?): SpanStyle? { + return when (name) { + "link" -> linkTextStyle().toSpanStyle() + else -> null + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/HtmlToComposable.kt b/app/src/main/java/me/ash/reader/ui/component/reader/HtmlToComposable.kt new file mode 100644 index 0000000..17d10ce --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/HtmlToComposable.kt @@ -0,0 +1,751 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.material.Text +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.annotation.ExperimentalCoilApi +import coil.size.Precision +import coil.size.Size +import coil.size.pxOrElse +import me.ash.reader.R +import me.ash.reader.ui.component.AsyncImage +import org.jsoup.Jsoup +import org.jsoup.helper.StringUtil +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import java.io.InputStream +import kotlin.math.abs +import kotlin.math.roundToInt + +fun LazyListScope.htmlFormattedText( + inputStream: InputStream, + baseUrl: String, + @DrawableRes imagePlaceholder: Int, + onLinkClick: (String) -> Unit, +) { + Jsoup.parse(inputStream, null, baseUrl) + ?.body() + ?.let { body -> + formatBody( + element = body, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } +} + +private fun LazyListScope.formatBody( + element: Element, + @DrawableRes imagePlaceholder: Int, + onLinkClick: (String) -> Unit, + baseUrl: String, +) { + val composer = TextComposer { paragraphBuilder -> + item { + val paragraph = paragraphBuilder.toAnnotatedString() + + // ClickableText prevents taps from deselecting selected text + // So use regular Text if possible + if (paragraph.getStringAnnotations("URL", 0, paragraph.length) + .isNotEmpty() + ) { + ClickableText( + text = paragraph, + style = bodyStyle(), + modifier = Modifier + .padding(horizontal = PADDING_HORIZONTAL.dp) + .width(MAX_CONTENT_WIDTH.dp) + ) { offset -> + paragraph.getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { + onLinkClick(it.item) + } + } + } else { + Text( + text = paragraph, + style = bodyStyle(), + modifier = Modifier + .padding(horizontal = PADDING_HORIZONTAL.dp) + .width(MAX_CONTENT_WIDTH.dp) + ) + } + } + } + + composer.appendTextChildren( + element.childNodes(), + lazyListScope = this, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + + composer.terminateCurrentText() +} + +private fun LazyListScope.formatCodeBlock( + element: Element, + @DrawableRes imagePlaceholder: Int, + onLinkClick: (String) -> Unit, + baseUrl: String, +) { + val composer = TextComposer { paragraphBuilder -> + item { + val scrollState = rememberScrollState() + Spacer(modifier = Modifier.height(8.dp)) + Surface( + color = codeBlockBackground(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(horizontal = PADDING_HORIZONTAL.dp), + ) { + Box( + modifier = Modifier + .padding(all = 8.dp) + .horizontalScroll( + state = scrollState + ) + .width(MAX_CONTENT_WIDTH.dp) + ) { + Text( + text = paragraphBuilder.toAnnotatedString(), + style = codeBlockStyle(), + softWrap = false + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + + composer.appendTextChildren( + element.childNodes(), preFormatted = true, + lazyListScope = this, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + + composer.terminateCurrentText() +} + +@OptIn(ExperimentalComposeApi::class, ExperimentalCoilApi::class) +private fun TextComposer.appendTextChildren( + nodes: List, + preFormatted: Boolean = false, + lazyListScope: LazyListScope, + @DrawableRes imagePlaceholder: Int, + onLinkClick: (String) -> Unit, + baseUrl: String, +) { + var node = nodes.firstOrNull() + while (node != null) { + when (node) { + is TextNode -> { + if (preFormatted) { + append(node.wholeText) + } else { + if (endsWithWhitespace) { + node.text().trimStart().let { trimmed -> + if (trimmed.isNotEmpty()) { + append(trimmed) + } + } + } else { + node.text().let { text -> + if (text.isNotEmpty()) { + append(text) + } + } + } + } + } + is Element -> { + val element = node + when (element.tagName()) { + "p" -> { + // Readability4j inserts p-tags in divs for algorithmic purposes. + // They screw up formatting. + if (node.hasClass("readability-styled")) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } else { + withParagraph { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "br" -> append('\n') + "h1" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "h2" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "h3" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "h4" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "h5" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "h6" -> { + withParagraph { + withComposableStyle( + style = { h5Style().toSpanStyle() } + ) { + append(element.text()) + } + } + } + "strong", "b" -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "i", "em", "cite", "dfn" -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "tt" -> { + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "u" -> { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "sup" -> { + withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "sub" -> { + withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "font" -> { + val fontFamily: FontFamily? = element.attr("face")?.asFontFamily() + withStyle(SpanStyle(fontFamily = fontFamily)) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + "pre" -> { + appendTextChildren( + element.childNodes(), + preFormatted = true, + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + "code" -> { + if (element.parent()?.tagName() == "pre") { + terminateCurrentText() + lazyListScope.formatCodeBlock( + element = element, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } else { + // inline code + withComposableStyle( + style = { codeInlineStyle() } + ) { + appendTextChildren( + element.childNodes(), + preFormatted = preFormatted, + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "blockquote" -> { + withParagraph { + withComposableStyle( + style = { blockQuoteStyle() } + ) { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "a" -> { + withComposableStyle( + style = { linkTextStyle().toSpanStyle() } + ) { + withAnnotation("URL", element.attr("abs:href") ?: "") { + appendTextChildren( + element.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "img" -> { + val imageCandidates = getImageSource(baseUrl, element) + if (imageCandidates.hasImage) { + val alt = element.attr("alt") ?: "" + appendImage(onLinkClick = onLinkClick) { onClick -> + lazyListScope.item { +// val scale = remember { mutableStateOf(1f) } + Column( + modifier = Modifier +// .padding(horizontal = PADDING_HORIZONTAL.dp) + .width(MAX_CONTENT_WIDTH.dp) + ) { + DisableSelection { + BoxWithConstraints( + modifier = Modifier + .clip(RectangleShape) + .clickable( + enabled = onClick != null + ) { + onClick?.invoke() + } + .fillMaxWidth() + // This makes scrolling a pain, find a way to solve that +// .pointerInput("imgzoom") { +// detectTransformGestures { centroid, pan, zoom, rotation -> +// val z = zoom * scale.value +// scale.value = when { +// z < 1f -> 1f +// z > 3f -> 3f +// else -> z +// } +// } +// } + ) { + val imageSize = maxImageSize() + AsyncImage( + modifier = Modifier.fillMaxWidth(), + data = imageCandidates.getBestImageForMaxSize( + pixelDensity = pixelDensity(), + maxSize = imageSize, + ), + contentDescription = alt, + size = imageSize, + precision = Precision.INEXACT, + contentScale = ContentScale.FillWidth, + ) + } + } + + if (alt.isNotBlank()) { + Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PADDING_HORIZONTAL.dp), + text = alt, + style = captionStyle(), + ) + } + + Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp)) + } + } + } + } + } + "ul" -> { + element.children() + .filter { it.tagName() == "li" } + .forEach { listItem -> + withParagraph { + // no break space + append("• ") + appendTextChildren( + listItem.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "ol" -> { + element.children() + .filter { it.tagName() == "li" } + .forEachIndexed { i, listItem -> + withParagraph { + // no break space + append("${i + 1}. ") + appendTextChildren( + listItem.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + "table" -> { + appendTable { + /* + In this order: + optionally a caption element (containing text children for instance), + followed by zero or more colgroup elements, + followed optionally by a thead element, + followed by either zero or more tbody elements + or one or more tr elements, + followed optionally by a tfoot element + */ + element.children() + .filter { it.tagName() == "caption" } + .forEach { + appendTextChildren( + it.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + ensureDoubleNewline() + terminateCurrentText() + } + + element.children() + .filter { it.tagName() == "thead" || it.tagName() == "tbody" || it.tagName() == "tfoot" } + .flatMap { + it.children() + .filter { it.tagName() == "tr" } + } + .forEach { row -> + appendTextChildren( + row.childNodes(), + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + terminateCurrentText() + } + + append("\n\n") + } + } + "iframe" -> { + val video: Video? = getVideo(element.attr("abs:src")) + + if (video != null) { + appendImage(onLinkClick = onLinkClick) { + lazyListScope.item { + Column( + modifier = Modifier + .padding(horizontal = PADDING_HORIZONTAL.dp) + .width(MAX_CONTENT_WIDTH.dp) + ) { + DisableSelection { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + ) { + AsyncImage( + modifier = Modifier + .clickable { + onLinkClick(video.link) + } + .fillMaxWidth(), + data = video.imageUrl, + size = maxImageSize(), + contentDescription = "点击播放视频", + precision = Precision.INEXACT, + contentScale = ContentScale.FillWidth, + ) + } + } + + Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PADDING_HORIZONTAL.dp), + text = "点击播放视频", + style = captionStyle(), + ) + + Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp)) + } + } + } + } + } + "video" -> { + // not implemented yet. remember to disable selection + } + else -> { + appendTextChildren( + nodes = element.childNodes(), + preFormatted = preFormatted, + lazyListScope = lazyListScope, + imagePlaceholder = imagePlaceholder, + onLinkClick = onLinkClick, + baseUrl = baseUrl, + ) + } + } + } + } + + node = node.nextSibling() + } +} + +@OptIn(ExperimentalStdlibApi::class) +private fun String.asFontFamily(): FontFamily? = when (this.lowercase()) { + "monospace" -> FontFamily.Monospace + "serif" -> FontFamily.Serif + "sans-serif" -> FontFamily.SansSerif + else -> null +} + +@Preview +@Composable +private fun testIt() { + val html = """ +

In Gimp you go to Image in the top menu bar and select Mode followed by Indexed. Now you see a popup where you can select the number of colors for a generated optimum palette.

You’ll have to experiment a little because it will depend on your image.

I used this approach to shrink the size of the cover image in the_zopfli post from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).

Straight JPG to PNG conversion: 124KB

PNG version RGB colors

First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.

256 colors: 40KB

Reducing from RGB to only 256 colors has no visible effect to my eyes.

256 colors

128 colors: 34KB

Still no difference.

128 colors

64 colors: 25KB

You can start to see some artifacting in the shadow behind the text.

64 colors

32 colors: 15KB

In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.

32 colors

16 colors: 11KB

Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.

16 colors

8 colors: 7.3KB

The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.

8 colors

4 colors: 4.3KB

Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.

4 colors

2 colors: 2.4KB

Well, at least the silhouette is well defined at this point I guess.

2 colors


Other posts in the Migrating from Ghost to Hugo series:

+ """.trimIndent() + + html.byteInputStream().use { stream -> + LazyColumn { + htmlFormattedText( + inputStream = stream, + baseUrl = "https://cowboyprogrammer.org", + imagePlaceholder = R.drawable.ic_telegram, + onLinkClick = {} + ) + } + } +} + +@Composable +private fun pixelDensity() = with(LocalDensity.current) { + density +} + +@Composable +private fun BoxWithConstraintsScope.maxImageSize() = with(LocalDensity.current) { + val maxWidthPx = maxWidth.toPx().roundToInt() + + Size( + width = maxWidth.toPx().roundToInt().coerceAtLeast(1), + height = maxHeight + .toPx() + .roundToInt() + .coerceAtLeast(1) + .coerceAtMost(10 * maxWidthPx), + ) +} + +/** + * Gets the url to the image in the tag - could be from srcset or from src + */ +internal fun getImageSource(baseUrl: String, element: Element) = ImageCandidates( + baseUrl = baseUrl, + srcSet = element.attr("srcset") ?: "", + absSrc = element.attr("abs:src") ?: "", +) + +internal class ImageCandidates( + val baseUrl: String, + val srcSet: String, + val absSrc: String +) { + val hasImage: Boolean = srcSet.isNotBlank() || absSrc.isNotBlank() + + /** + * Might throw if hasImage returns false + */ + fun getBestImageForMaxSize(maxSize: Size, pixelDensity: Float): String { + val setCandidate = srcSet.splitToSequence(",") + .map { it.trim() } + .map { it.split(SpaceRegex).take(2).map { x -> x.trim() } } + .fold(100f to "") { acc, candidate -> + val candidateSize = if (candidate.size == 1) { + // Assume it corresponds to 1x pixel density + 1.0f / pixelDensity + } else { + val descriptor = candidate.last() + when { + descriptor.endsWith("w", ignoreCase = true) -> { + descriptor.substringBefore("w").toFloat() / maxSize.width.pxOrElse { 1 } + } + descriptor.endsWith("x", ignoreCase = true) -> { + descriptor.substringBefore("x").toFloat() / pixelDensity + } + else -> { + return@fold acc + } + } + } + + if (abs(candidateSize - 1.0f) < abs(acc.first - 1.0f)) { + candidateSize to candidate.first() + } else { + acc + } + } + .second + + if (setCandidate.isNotBlank()) { + return StringUtil.resolve(baseUrl, setCandidate) + } + return StringUtil.resolve(baseUrl, absSrc) + } +} + +private val SpaceRegex = Regex("\\s+") diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt new file mode 100644 index 0000000..0a47d3a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt @@ -0,0 +1,49 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.foundation.lazy.LazyListScope +import me.ash.reader.R + +fun LazyListScope.reader( + context: Context, + link: String, + content: String, +) { + Log.i("RLog", "Reader: ") + htmlFormattedText( + inputStream = content.byteInputStream(), + baseUrl = link, + imagePlaceholder = R.drawable.ic_launcher_foreground, + onLinkClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(it) + ) + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt new file mode 100644 index 0000000..7ed82e9 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt @@ -0,0 +1,122 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +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.unit.dp +import me.ash.reader.ui.ext.alphaLN + +const val PADDING_HORIZONTAL = 24.0 +const val MAX_CONTENT_WIDTH = 840.0 + +@Composable +fun bodyForeground(): Color = + MaterialTheme.colorScheme.onSurfaceVariant + +@Composable +fun bodyStyle(): TextStyle = + MaterialTheme.typography.bodyLarge.copy( + color = bodyForeground() + ) + +@Composable +fun h1Style(): TextStyle = + MaterialTheme.typography.displayMedium.copy( + color = bodyForeground() + ) + +@Composable +fun h2Style(): TextStyle = + MaterialTheme.typography.displaySmall.copy( + color = bodyForeground() + ) + +@Composable +fun h3Style(): TextStyle = + MaterialTheme.typography.headlineLarge.copy( + color = bodyForeground() + ) + +@Composable +fun h4Style(): TextStyle = + MaterialTheme.typography.headlineMedium.copy( + color = bodyForeground() + ) + +@Composable +fun h5Style(): TextStyle = + MaterialTheme.typography.headlineSmall.copy( + color = bodyForeground() + ) + +@Composable +fun h6Style(): TextStyle = + MaterialTheme.typography.titleLarge.copy( + color = bodyForeground() + ) + +@Composable +fun captionStyle(): TextStyle = + MaterialTheme.typography.bodySmall.copy( + color = bodyForeground().copy(alpha = 0.6f) + ) + +@Composable +fun linkTextStyle(): TextStyle = + TextStyle( + color = MaterialTheme.colorScheme.secondary, + textDecoration = TextDecoration.Underline + ) + +@Composable +fun codeBlockStyle(): TextStyle = + MaterialTheme.typography.titleSmall.merge( + SpanStyle( + color = bodyForeground(), + fontFamily = FontFamily.Monospace + ) + ) + +@Composable +fun codeBlockBackground(): Color = + MaterialTheme.colorScheme.secondary.copy(alpha = (0.dp).alphaLN(weight = 3.2f)) + +@Composable +fun blockQuoteStyle(): SpanStyle = + MaterialTheme.typography.titleSmall.toSpanStyle().merge( + SpanStyle( + fontWeight = FontWeight.Light + ) + ) + +@Composable +fun codeInlineStyle(): SpanStyle = + MaterialTheme.typography.titleSmall.toSpanStyle().copy( + color = bodyForeground(), + fontFamily = FontFamily.Monospace, + ) diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt b/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt new file mode 100644 index 0000000..e38d0e6 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt @@ -0,0 +1,182 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle + +class TextComposer( + val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit +) { + val spanStack: MutableList = mutableListOf() + + // The identity of this will change - do not reference it in blocks + private var builder: AnnotatedParagraphStringBuilder = AnnotatedParagraphStringBuilder() + + fun terminateCurrentText() { + if (builder.isEmpty()) { + // Nothing to emit, and nothing to reset + return + } + + paragraphEmitter(builder) + + builder = AnnotatedParagraphStringBuilder() + + for (span in spanStack) { + when (span) { + is SpanWithStyle -> builder.pushStyle(span.spanStyle) + is SpanWithAnnotation -> builder.pushStringAnnotation( + tag = span.tag, + annotation = span.annotation + ) + is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle) + } + } + } + + val endsWithWhitespace: Boolean + get() = builder.endsWithWhitespace + + fun ensureDoubleNewline() = + builder.ensureDoubleNewline() + + fun append(text: String) = + builder.append(text) + + fun append(char: Char) = + builder.append(char) + + fun appendTable(block: () -> R): R { + builder.ensureDoubleNewline() + terminateCurrentText() + return block() + } + + fun appendImage( + link: String? = null, + onLinkClick: (String) -> Unit, + block: ( + onClick: (() -> Unit)? + ) -> R + ): R { + val url = link ?: findClosestLink() + builder.ensureDoubleNewline() + terminateCurrentText() + val onClick: (() -> Unit)? = if (url?.isNotBlank() == true) { + { + onLinkClick(url) + } + } else { + null + } + return block(onClick) + } + + fun pop(index: Int) = + builder.pop(index) + + fun pushStyle(style: SpanStyle): Int = + builder.pushStyle(style) + + fun pushStringAnnotation(tag: String, annotation: String): Int = + builder.pushStringAnnotation(tag = tag, annotation = annotation) + + fun pushComposableStyle(style: @Composable () -> SpanStyle): Int = + builder.pushComposableStyle(style) + + fun popComposableStyle(index: Int) = + builder.popComposableStyle(index) + + private fun findClosestLink(): String? { + for (span in spanStack.reversed()) { + if (span is SpanWithAnnotation && span.tag == "URL") { + return span.annotation + } + } + return null + } +} + +inline fun TextComposer.withParagraph( + crossinline block: TextComposer.() -> R +): R { + ensureDoubleNewline() + return block(this) +} + +inline fun TextComposer.withStyle( + style: SpanStyle, + crossinline block: TextComposer.() -> R +): R { + spanStack.add(SpanWithStyle(style)) + val index = pushStyle(style) + return try { + block() + } finally { + pop(index) + spanStack.removeLast() + } +} + +inline fun TextComposer.withComposableStyle( + noinline style: @Composable () -> SpanStyle, + crossinline block: TextComposer.() -> R +): R { + spanStack.add(SpanWithComposableStyle(style)) + val index = pushComposableStyle(style) + return try { + block() + } finally { + popComposableStyle(index) + spanStack.removeLast() + } +} + +inline fun TextComposer.withAnnotation( + tag: String, + annotation: String, + crossinline block: TextComposer.() -> R +): R { + spanStack.add(SpanWithAnnotation(tag = tag, annotation = annotation)) + val index = pushStringAnnotation(tag = tag, annotation = annotation) + return try { + block() + } finally { + pop(index) + spanStack.removeLast() + } +} + +sealed class Span + +data class SpanWithStyle( + val spanStyle: SpanStyle +) : Span() + +data class SpanWithAnnotation( + val tag: String, + val annotation: String +) : Span() + +data class SpanWithComposableStyle( + val spanStyle: @Composable () -> SpanStyle +) : Span() diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt b/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt new file mode 100644 index 0000000..084ce6a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt @@ -0,0 +1,54 @@ +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.ui.component.reader + +// Example strings +// www.youtube.com/embed/cjxnVO9RpaQ +// www.youtube.com/embed/cjxnVO9RpaQ?feature=oembed +// www.youtube.com/embed/cjxnVO9RpaQ/theoretical_crap +// www.youtube.com/embed/cjxnVO9RpaQ/crap?feature=oembed +internal val YoutubeIdPattern = "youtube.com/embed/([^?/]*)".toRegex() + +fun getVideo(src: String?): Video? { + return src?.let { + YoutubeIdPattern.find(src)?.let { match -> + val videoId = match.groupValues[1] + Video( + src = src, + imageUrl = "http://img.youtube.com/vi/$videoId/hqdefault.jpg", + link = "https://www.youtube.com/watch?v=$videoId" + ) + } + } +} + +data class Video( + val src: String, + val imageUrl: String, + // Youtube needs a different link than embed links + val link: String +) { + val width: Int + get() = 480 + + val height: Int + get() = 360 +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt index ce85a11..1f24737 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt @@ -27,6 +27,7 @@ package me.ash.reader.ui.ext */ import android.view.ViewConfiguration +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState @@ -88,7 +89,7 @@ fun Modifier.drawHorizontalScrollbar( fun Modifier.drawVerticalScrollbar( state: LazyListState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling).animateContentSize() private fun Modifier.drawScrollbar( state: ScrollState, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt index 8e51c6e..ec642b1 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt @@ -7,7 +7,7 @@ import androidx.compose.material.icons.Icons 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.TextFormat +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 @@ -108,9 +108,9 @@ fun ReadBar( view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } CanBeDisabledIconButton( - modifier = Modifier.size(40.dp), + modifier = Modifier.size(36.dp), disabled = true, - imageVector = Icons.Outlined.TextFormat, + imageVector = Icons.Outlined.Headphones, contentDescription = "Add Tag", tint = MaterialTheme.colorScheme.outline, ) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt index d9e0fd8..c2a5e7b 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt @@ -1,20 +1,20 @@ package me.ash.reader.ui.page.home.read -import android.util.Log +import android.content.Intent import androidx.compose.animation.* -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Headphones -import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -23,7 +23,7 @@ import androidx.navigation.NavHostController import me.ash.reader.R import me.ash.reader.data.entity.ArticleWithFeed import me.ash.reader.ui.component.FeedbackIconButton -import me.ash.reader.ui.component.WebView +import me.ash.reader.ui.component.reader.reader import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.drawVerticalScrollbar @@ -34,7 +34,8 @@ fun ReadPage( readViewModel: ReadViewModel = hiltViewModel(), ) { val viewState = readViewModel.viewState.collectAsStateValue() - var isScrollDown by remember { mutableStateOf(false) } + val isScrollDown = viewState.listState.isScrollDown() +// val isScrollDown by remember { mutableStateOf(false) } LaunchedEffect(Unit) { navController.currentBackStackEntryFlow.collect { @@ -44,24 +45,7 @@ fun ReadPage( } } - if (viewState.scrollState.isScrollInProgress) { - LaunchedEffect(Unit) { - Log.i("RLog", "scroll: start") - } - - val preScrollOffset by remember { mutableStateOf(viewState.scrollState.value) } - val currentOffset = viewState.scrollState.value - isScrollDown = currentOffset > preScrollOffset - - DisposableEffect(Unit) { - onDispose { - Log.i("RLog", "scroll: end") - } - } - } - LaunchedEffect(viewState.articleWithFeed?.article?.id) { - isScrollDown = false viewState.articleWithFeed?.let { if (it.article.isUnread) { readViewModel.dispatch(ReadViewAction.MarkUnread(false)) @@ -82,7 +66,8 @@ fun ReadPage( ) { TopBar( isShow = viewState.articleWithFeed == null || !isScrollDown, - isShowActions = viewState.articleWithFeed != null, + title = viewState.articleWithFeed?.article?.title, + link = viewState.articleWithFeed?.article?.link, onClose = { navController.popBackStack() }, @@ -91,8 +76,8 @@ fun ReadPage( Content( content = viewState.content ?: "", articleWithFeed = viewState.articleWithFeed, - viewState = viewState, - scrollState = viewState.scrollState, + isLoading = viewState.isLoading, + listState = viewState.listState, ) Box( modifier = Modifier @@ -121,12 +106,39 @@ fun ReadPage( ) } +@Composable +fun LazyListState.isScrollDown(): Boolean { + var isScrollDown by remember { mutableStateOf(false) } + var preItemIndex by remember { mutableStateOf(0) } + var preScrollStartOffset by remember { mutableStateOf(0) } + + LaunchedEffect(this) { + snapshotFlow { isScrollInProgress }.collect { + if (isScrollInProgress) { + isScrollDown = when { + firstVisibleItemIndex > preItemIndex -> true + firstVisibleItemScrollOffset < preItemIndex -> false + else -> firstVisibleItemScrollOffset > preScrollStartOffset + } + } else { + preItemIndex = firstVisibleItemIndex + preScrollStartOffset = firstVisibleItemScrollOffset + } + } + } + + return isScrollDown +} + @Composable private fun TopBar( isShow: Boolean, - isShowActions: Boolean = false, + title: String? = "", + link: String? = "", onClose: () -> Unit = {}, ) { + val context = LocalContext.current + AnimatedVisibility( visible = isShow, enter = fadeIn() + expandVertically(), @@ -148,23 +160,19 @@ private fun TopBar( } }, actions = { - if (isShowActions) { - FeedbackIconButton( - modifier = Modifier - .size(22.dp) - .alpha(0.5f), - imageVector = Icons.Outlined.Headphones, - contentDescription = stringResource(R.string.mark_all_as_read), - tint = MaterialTheme.colorScheme.onSurface, - ) { - } - FeedbackIconButton( - modifier = Modifier.alpha(0.5f), - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurface, - ) { - } + FeedbackIconButton( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurface, + ) { + context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + putExtra( + Intent.EXTRA_TEXT, + title?.takeIf { it.isNotBlank() }?.let { it + "\n" } + link + ) + type = "text/plain" + }, "Share")) } } ) @@ -175,37 +183,37 @@ private fun TopBar( private fun Content( content: String, articleWithFeed: ArticleWithFeed?, - viewState: ReadViewState, - scrollState: ScrollState = rememberScrollState(), + listState: LazyListState, + isLoading: Boolean, ) { - Column( - modifier = Modifier - .statusBarsPadding() - .navigationBarsPadding() - .drawVerticalScrollbar(scrollState) - .verticalScroll(scrollState), - ) { - if (articleWithFeed == null) { - Spacer(modifier = Modifier.height(64.dp)) -// LottieAnimation( -// modifier = Modifier -// .alpha(0.7f) -// .padding(80.dp), -// url = "https://assets8.lottiefiles.com/packages/lf20_jm7mv1ib.json", -// ) - } else { - Column { + if (articleWithFeed == null) return + val context = LocalContext.current + + SelectionContainer { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .drawVerticalScrollbar(listState), + state = listState, + ) { + item { Spacer(modifier = Modifier.height(64.dp)) - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(22.dp)) Column( modifier = Modifier .padding(horizontal = 12.dp) ) { - Header(articleWithFeed) + DisableSelection { + Header(articleWithFeed) + } } + } + item { Spacer(modifier = Modifier.height(22.dp)) AnimatedVisibility( - visible = viewState.isLoading, + visible = isLoading, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { @@ -224,12 +232,15 @@ private fun Content( } } } - if (!viewState.isLoading) { - WebView( - content = content - ) - Spacer(modifier = Modifier.height(50.dp)) - } + } + if (!isLoading) { + reader( + context = context, + link = articleWithFeed.article.link, + content = content + ) + } + item { Spacer(modifier = Modifier.height(64.dp)) Spacer(modifier = Modifier.height(64.dp)) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt index a947583..1aeadf2 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt @@ -1,7 +1,7 @@ package me.ash.reader.ui.page.home.read import android.util.Log -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -146,7 +146,8 @@ data class ReadViewState( val articleWithFeed: ArticleWithFeed? = null, val content: String? = null, val isLoading: Boolean = true, - val scrollState: ScrollState = ScrollState(0), +// val scrollState: ScrollState = ScrollState(0), + val listState: LazyListState = LazyListState(), ) sealed class ReadViewAction { diff --git a/app/src/main/res/drawable/ic_broken_image_black_24dp.xml b/app/src/main/res/drawable/ic_broken_image_black_24dp.xml new file mode 100644 index 0000000..6872d2e --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml b/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml new file mode 100644 index 0000000..ca58686 --- /dev/null +++ b/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml @@ -0,0 +1,9 @@ + + +