Kill Legacy TextView (#1786)

* Kill Legacy TextView

* Fix textStyle

* Fix color

* Add breakToTextAndUrl

* Impl breakToTextAndUrl

* Fix OOB

* Update GalleryCommentsScreen.kt

* Fix bug

* Fix color

* Refine regex

* Add undeline as well

* Refine

* Revert

* Don't break if not show image

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
This commit is contained in:
renovate[bot]andDude so hot authored and GitHub committed 2024-10-24 23:38:27 +08:00
1 parent 22f31f2ebc
commit 62927cb6a8
8 files changed
+141 -249

No files matched your search

@@ -1,33 +0,0 @@
package com.hippo.ehviewer.ui.legacy
import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.crossfade
import coil3.size.Size
import com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
private val textView: TextView,
) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
with(textView.context) {
imageLoader.enqueue(
imageRequest {
data(source)
crossfade(false)
size(Size.ORIGINAL)
target { drawable ->
setDrawable(drawable.asDrawable(resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
}
}
}
@@ -1,58 +0,0 @@
/*
* Copyright 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.content.Context
import android.text.Spanned
import android.text.style.ClickableSpan
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
class LinkifyTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
private val onCurrentSpanUpdate: (ClickableSpan?) -> Unit,
) : AppCompatTextView(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean {
// Let the parent or grandparent of TextView to handles click aciton.
// Otherwise click effect like ripple will not work, and if touch area
// do not contain a url, the TextView will still get MotionEvent.
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
// action on it, so we analyze touched url here.
if (event.action == MotionEvent.ACTION_DOWN) {
onCurrentSpanUpdate(null)
if (text is Spanned) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= totalPaddingLeft
y -= totalPaddingTop
x += scrollX
y += scrollY
val layout = layout
if (null != layout) {
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val spans = (text as Spanned).getSpans(off, off, ClickableSpan::class.java)
if (spans.isNotEmpty()) {
onCurrentSpanUpdate(spans[0])
}
}
}
}
return super.onTouchEvent(event)
}
}
@@ -1,32 +1,36 @@
package com.hippo.ehviewer.ui.main
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import arrow.core.Either
import coil3.compose.AsyncImage
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.ui.legacy.LinkifyTextView
import com.hippo.ehviewer.ui.screen.breakToTextAndUrl
import com.hippo.ehviewer.util.ReadableTime
typealias TextOrUrl = Either<String, AnnotatedString>
typealias TextOrUrlList = List<TextOrUrl>
@Composable
fun GalleryCommentCard(
modifier: Modifier = Modifier,
@@ -34,22 +38,14 @@ fun GalleryCommentCard(
onCardClick: () -> Unit,
onUserClick: () -> Unit,
onUrlClick: (String) -> Unit,
update: LinkifyTextView.() -> Unit,
) = comment.run {
var currentSpan by remember(comment) { mutableStateOf<ClickableSpan?>(null) }
Card(
onClick = {
val span = currentSpan.apply { currentSpan = null }
if (span is URLSpan) {
onUrlClick(span.url)
} else {
onCardClick()
}
},
modifier = modifier,
) {
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
ConstraintLayout(modifier = Modifier.padding(horizontal = keylineMargin, vertical = 8.dp).fillMaxWidth()) {
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
showImage: Boolean = false,
processComment: @Composable (GalleryComment, TextLinkStyles, LinkInteractionListener) -> AnnotatedString = { c, s, l -> AnnotatedString.fromHtml(c.comment, s, l) },
) = with(comment) {
Card(onClick = onCardClick, modifier = modifier) {
val margin = dimensionResource(id = R.dimen.keyline_margin)
ConstraintLayout(modifier = Modifier.padding(horizontal = margin, vertical = 8.dp).fillMaxWidth()) {
val (userRef, timeRef, commentRef) = createRefs()
ProvideTextStyle(value = MaterialTheme.typography.titleSmall) {
val userText = if (uploader) stringResource(id = R.string.comment_user_uploader, user.orEmpty()) else user.orEmpty()
@@ -68,21 +64,41 @@ fun GalleryCommentCard(
},
)
}
val textColor = LocalContentColor.current.toArgb()
val linkTextColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
factory = { context ->
LinkifyTextView(context) { currentSpan = it }.apply {
setTextColor(textColor)
setLinkTextColor(linkTextColor)
}
},
Column(
modifier = Modifier.constrainAs(commentRef) {
start.linkTo(parent.start)
top.linkTo(userRef.bottom, margin = 8.dp)
},
update = update,
)
) {
ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) {
val linkColor = MaterialTheme.colorScheme.primary
val processed = processComment(
comment,
TextLinkStyles(style = SpanStyle(color = linkColor)),
) { link ->
check(link is LinkAnnotation.Url)
onUrlClick(link.url)
}
if (showImage) {
val list = breakToTextAndUrl(comment.comment, processed)
list.forEach {
when (it) {
is Either.Left -> AsyncImage(
model = it.value,
contentDescription = null,
)
is Either.Right -> Text(text = it.value)
}
}
} else {
Text(
text = processed,
maxLines = maxLines,
overflow = overflow,
)
}
}
}
}
}
}
@@ -1,12 +1,5 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.widget.TextView
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
@@ -67,14 +60,23 @@ import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.lerp
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import arrow.core.left
import arrow.core.right
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
@@ -87,8 +89,9 @@ import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.TextOrUrl
import com.hippo.ehviewer.ui.main.TextOrUrlList
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.normalizeSpan
@@ -98,7 +101,6 @@ import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.ui.tools.toBBCode
import com.hippo.ehviewer.ui.tools.updateSpan
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.TextUrl
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.annotation.Destination
@@ -108,41 +110,68 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlin.sequences.forEach
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.res.resolveColor
private fun Context.generateComment(
textView: TextView,
private const val IMAGE_OBJ = ''
private val IMAGE_PATTERN = Regex("<img src=\"([^\"]+)\"")
private val URL_PATTERN = Regex("(http|https)://[a-z0-9A-Z%-]+(\\.[a-z0-9A-Z%-]+)+(:\\d{1,5})?(/[a-zA-Z0-9-_~:#@!&',;=%/*.?+$\\[\\]()]+)?/?")
fun breakToTextAndUrl(origin: String, text: AnnotatedString): TextOrUrlList {
val urls = IMAGE_PATTERN.findAll(origin).toList()
if (urls.isEmpty()) return listOf(text.right())
val iter = urls.iterator()
var currentOfs = 0
return buildList<TextOrUrl> {
while (true) {
val index = text.text.indexOf(IMAGE_OBJ, currentOfs)
if (index == -1) break
add(text.subSequence(currentOfs, index).right())
add(iter.next().groupValues[1].left())
currentOfs = index + 1
}
add(text.subSequence(currentOfs, text.length).right())
}
}
@Composable
fun processComment(
comment: GalleryComment,
): CharSequence {
val sp = comment.comment.parseAsHtml(imageGetter = CoilImageGetter(textView))
val ssb = SpannableStringBuilder(sp)
if (0L != comment.id && 0 != comment.score) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
ssb.append(" ").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(scoreString)
linkStyle: TextLinkStyles,
onLinkClick: LinkInteractionListener,
) = AnnotatedString.fromHtml(comment.comment, linkStyle, onLinkClick).let { text ->
buildAnnotatedString {
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
append(text)
URL_PATTERN.findAll(text).forEach { result ->
val start = result.range.first
val end = result.range.last + 1
if (!text.hasLinkAnnotations(start, end)) {
addLink(LinkAnnotation.Url(result.groupValues[0], linkStyle, onLinkClick), start, end)
}
}
val style = SpanStyle(fontSize = 0.8f.em, fontWeight = FontWeight.Bold, color = onSurfaceVariant)
if (comment.id != 0L && comment.score != 0) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
append(" ")
withStyle(style) {
append(scoreString)
}
}
if (comment.lastEdited != 0L) {
append("\n\n")
withStyle(style) {
append(
stringResource(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
),
)
}
}
}
if (comment.lastEdited != 0L) {
val str = getString(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
)
ssb.append("\n\n").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(str)
}
}
return TextUrl.handleTextUrl(ssb)
}
private val MinimumContentPaddingEditText = 88.dp
@@ -380,9 +409,9 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
},
onCardClick = { launch { doCommentAction(item) } },
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
text = generateComment(this, item)
}
processComment = { c, s, l -> processComment(c, s, l) },
showImage = true,
)
}
if (comments.hasMore) {
item {
@@ -3,7 +3,6 @@ package com.hippo.ehviewer.ui.screen
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.text.TextUtils.TruncateAt.END
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.MutatorMutex
@@ -52,9 +51,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
@@ -101,7 +100,6 @@ import com.hippo.ehviewer.ui.confirmRemoveDownload
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
@@ -411,11 +409,10 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
onCardClick = ::onNavigateToCommentScene,
onUserClick = ::onNavigateToCommentScene,
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
maxLines = 5
ellipsize = END
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
}
maxLines = 5,
overflow = TextOverflow.Ellipsis,
showImage = false,
)
}
Box(
modifier = Modifier
@@ -15,6 +15,9 @@
*/
package com.hippo.ehviewer.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.hippo.ehviewer.R
import java.util.Locale
import kotlinx.datetime.Clock
@@ -95,38 +98,28 @@ object ReadableTime {
}
private val resources = appCtx.resources
@Composable
fun getTimeAgo(time: Long): String {
val nowInstant = Clock.System.now()
val now = nowInstant.toEpochMilliseconds()
val diff = now - time
return when {
(diff < 0 || time <= 0) -> {
resources.getString(R.string.from_the_future)
}
diff < MINUTE_MILLIS -> {
resources.getString(R.string.just_now)
}
diff < 2 * MINUTE_MILLIS -> {
resources.getQuantityString(R.plurals.some_minutes_ago, 1, 1)
}
(diff < 0 || time <= 0) -> stringResource(id = R.string.from_the_future)
diff < MINUTE_MILLIS -> stringResource(id = R.string.just_now)
diff < 2 * MINUTE_MILLIS -> pluralStringResource(R.plurals.some_minutes_ago, 1, 1)
diff < 50 * MINUTE_MILLIS -> {
val minutes = (diff / MINUTE_MILLIS).toInt()
resources.getQuantityString(R.plurals.some_minutes_ago, minutes, minutes)
}
diff < 90 * MINUTE_MILLIS -> {
resources.getQuantityString(R.plurals.some_hours_ago, 1, 1)
pluralStringResource(R.plurals.some_minutes_ago, minutes, minutes)
}
diff < 90 * MINUTE_MILLIS -> pluralStringResource(R.plurals.some_hours_ago, 1, 1)
diff < 24 * HOUR_MILLIS -> {
val hours = (diff / HOUR_MILLIS).toInt()
resources.getQuantityString(R.plurals.some_hours_ago, hours, hours)
pluralStringResource(R.plurals.some_hours_ago, hours, hours)
}
diff < 48 * HOUR_MILLIS -> {
resources.getString(R.string.yesterday)
}
diff < WEEK_MILLIS -> {
val days = (diff / DAY_MILLIS).toInt()
resources.getString(R.string.some_days_ago, days)
stringResource(id = R.string.yesterday)
}
diff < WEEK_MILLIS -> stringResource(R.string.some_days_ago, (diff / DAY_MILLIS).toInt())
else -> {
val timeZone = TimeZone.currentSystemDefault()
val nowDate = nowInstant.toLocalDateTime(timeZone).date
@@ -1,39 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.util
import android.text.Spannable
import android.text.style.URLSpan
object TextUrl {
private val URL_PATTERN =
Regex("(http|https)://[a-z0-9A-Z%-]+(\\.[a-z0-9A-Z%-]+)+(:\\d{1,5})?(/[a-zA-Z0-9-_~:#@!&',;=%/*.?+$\\[\\]()]+)?/?")
fun handleTextUrl(content: Spannable): Spannable {
URL_PATTERN.findAll(content).forEach { result ->
val start = result.range.first
val end = result.range.last + 1
val links = content.getSpans(start, end, URLSpan::class.java)
if (links.isNotEmpty()) {
// There has been URLSpan already, leave it alone
return@forEach
}
val urlSpan = URLSpan(result.groupValues[0])
content.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return content
}
}
@@ -1,13 +0,0 @@
package rikka.core.res
import android.content.res.Resources
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
@ColorInt
fun Resources.Theme.resolveColor(@AttrRes attrId: Int): Int {
val a = obtainStyledAttributes(intArrayOf(attrId))
val res = a.getColor(0, 0)
a.recycle()
return res
}