From b8ca7be7e2e5f231756d7b9fd6485a2a1a2c1dd6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 11 Sep 2026 21:16:13 +0200 Subject: [PATCH 1/3] feat: parse safe markup in comment text --- app/build.gradle.kts | 1 + .../player/components/RichMarkupParser.kt | 137 ++++++++++++++++++ .../player/components/RichMarkupParserTest.kt | 131 +++++++++++++++++ gradle/libs.versions.toml | 2 + 4 files changed, 271 insertions(+) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/player/components/RichMarkupParserTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f44ef1a9..987624a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -140,6 +140,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.jsoup) implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt b/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt new file mode 100644 index 00000000..4750fcc6 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt @@ -0,0 +1,137 @@ +package dev.typetype.android.feature.player.components + +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import java.net.URI + +internal enum class RichMarkupFormat { + Strong, + Emphasized, + Underline, + Strikethrough, + Code, + Keyboard, + Highlight, +} + +internal sealed interface RichMarkupNode { + data class Text(val value: String) : RichMarkupNode + + data object Break : RichMarkupNode + + data class Link(val href: String, val children: List) : RichMarkupNode + + data class Format(val format: RichMarkupFormat, val children: List) : RichMarkupNode +} + +private val formatTags = setOf( + "b", "strong", "em", "i", "u", "s", "strike", "del", "code", "kbd", "mark", +) +private val blockTags = setOf( + "address", "article", "aside", "blockquote", "div", "li", "p", "pre", +) +private val omittedTags = setOf( + "audio", "base", "embed", "form", "iframe", "img", "link", "meta", + "object", "script", "style", "svg", "template", "video", +) +private val escapedMarkupPattern = Regex( + pattern = """<\s*(?:a|br|b|strong|em|i|u|s|p|div)\b""", + options = setOf(RegexOption.IGNORE_CASE), +) + +internal fun parseRichMarkup(source: String): List { + val document = Jsoup.parseBodyFragment(source).body() + val nodes = parseChildren(document.childNodes()) + val markupFree = nodes.all { it is RichMarkupNode.Text } + if (markupFree && escapedMarkupPattern.containsMatchIn(source)) { + val decoded = document.text() + if (decoded != source) { + return parseChildren(Jsoup.parseBodyFragment(decoded).body().childNodes()) + } + } + return nodes +} + +internal fun richMarkupPlainText(source: String): String { + val builder = StringBuilder() + fun visit(nodes: List) { + nodes.forEach { node -> + when (node) { + is RichMarkupNode.Text -> builder.append(node.value) + is RichMarkupNode.Break -> builder.append('\n') + is RichMarkupNode.Link -> visit(node.children) + is RichMarkupNode.Format -> visit(node.children) + } + } + } + visit(parseRichMarkup(source)) + return builder.toString() +} + +private fun parseChildren(nodes: List): List { + val result = mutableListOf() + nodes.forEach { node -> + when (node) { + is TextNode -> if (node.wholeText.isNotEmpty()) { + result += RichMarkupNode.Text(node.wholeText) + } + is Element -> parseElement(node, result) + else -> Unit + } + } + return result +} + +private fun parseElement(element: Element, result: MutableList) { + val tag = element.tagName().lowercase() + if (tag in omittedTags) return + if (tag == "br") { + appendBreak(result) + return + } + val children = parseChildren(element.childNodes()) + if (tag == "a") { + val href = element.attr("href") + if (isSafeHttpUrl(href)) { + result += RichMarkupNode.Link(href, children) + } else { + result += children + } + return + } + formatForTag(tag)?.let { format -> + result += RichMarkupNode.Format(format, children) + return + } + if (tag in blockTags) { + appendBreak(result) + result += children + appendBreak(result) + return + } + result += children +} + +private fun formatForTag(tag: String): RichMarkupFormat? = when (tag) { + "b", "strong" -> RichMarkupFormat.Strong + "em", "i" -> RichMarkupFormat.Emphasized + "u" -> RichMarkupFormat.Underline + "s", "strike", "del" -> RichMarkupFormat.Strikethrough + "code" -> RichMarkupFormat.Code + "kbd" -> RichMarkupFormat.Keyboard + "mark" -> RichMarkupFormat.Highlight + else -> null +} + +private fun appendBreak(nodes: MutableList) { + if (nodes.isNotEmpty() && nodes.last() != RichMarkupNode.Break) { + nodes += RichMarkupNode.Break + } +} + +private fun isSafeHttpUrl(value: String): Boolean = runCatching { + val scheme = URI(value).scheme ?: return@runCatching false + scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true) +}.getOrDefault(false) diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/RichMarkupParserTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/RichMarkupParserTest.kt new file mode 100644 index 00000000..21fb92b3 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/components/RichMarkupParserTest.kt @@ -0,0 +1,131 @@ +package dev.typetype.android.feature.player.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RichMarkupParserTest { + + @Test + fun `keeps plain text untouched`() { + assertEquals( + listOf(RichMarkupNode.Text("Just a normal comment")), + parseRichMarkup("Just a normal comment"), + ) + } + + @Test + fun `decodes entities in text`() { + assertEquals( + listOf(RichMarkupNode.Text("\"soft\" 5 > 3 & 2 < 4")), + parseRichMarkup(""soft" 5 > 3 & 2 < 4"), + ) + } + + @Test + fun `turns breaks into break nodes`() { + assertEquals( + listOf( + RichMarkupNode.Text("first"), + RichMarkupNode.Break, + RichMarkupNode.Text("second"), + ), + parseRichMarkup("first
second"), + ) + } + + @Test + fun `keeps safe anchors as links with decoded href`() { + val link = parseRichMarkup("""watch""") + .filterIsInstance() + .single() + + assertEquals("https://example.test/watch?v=abc&t=37", link.href) + assertEquals(listOf(RichMarkupNode.Text("watch")), link.children) + } + + @Test + fun `unwraps anchors with unsafe schemes`() { + assertEquals( + listOf(RichMarkupNode.Text("trick")), + parseRichMarkup("""trick"""), + ) + } + + @Test + fun `maps formatting tags to formats`() { + val nodes = parseRichMarkup( + "boldstrongemi" + + "usstrikedel" + + "codekbdmark", + ).filterIsInstance() + + assertEquals( + listOf( + RichMarkupFormat.Strong, + RichMarkupFormat.Strong, + RichMarkupFormat.Emphasized, + RichMarkupFormat.Emphasized, + RichMarkupFormat.Underline, + RichMarkupFormat.Strikethrough, + RichMarkupFormat.Strikethrough, + RichMarkupFormat.Strikethrough, + RichMarkupFormat.Code, + RichMarkupFormat.Keyboard, + RichMarkupFormat.Highlight, + ), + nodes.map { it.format }, + ) + } + + @Test + fun `omits dangerous and media tags with their content`() { + assertEquals( + listOf(RichMarkupNode.Text("safe")), + parseRichMarkup("safe"), + ) + } + + @Test + fun `adds single breaks around block tags`() { + assertEquals( + listOf( + RichMarkupNode.Text("before"), + RichMarkupNode.Break, + RichMarkupNode.Text("inside"), + RichMarkupNode.Break, + RichMarkupNode.Text("after"), + ), + parseRichMarkup("before
inside
after"), + ) + } + + @Test + fun `reparses double escaped markup`() { + val nodes = parseRichMarkup( + "<a href=\"https://example.test\">link</a>", + ) + + assertEquals( + listOf( + RichMarkupNode.Link("https://example.test", listOf(RichMarkupNode.Text("link"))), + ), + nodes, + ) + } + + @Test + fun `flattens plain text for truncation`() { + val source = "look
bold and " + + """a link""" + + assertEquals("look\nbold and a link", richMarkupPlainText(source)) + } + + @Test + fun `unwraps unknown tags`() { + assertEquals( + listOf(RichMarkupNode.Text("kept")), + parseRichMarkup("kept"), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66f59e62..de5d609c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ datastore = "1.2.1" securityCrypto = "1.1.0" tink = "1.23.0" okhttp = "5.4.0" +jsoup = "1.23.2" aboutlibraries = "15.1.1" [libraries] @@ -64,6 +65,7 @@ androidx-security-crypto = { group = "androidx.security", name = "security-crypt google-tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } From 812d3507ab831f0e848836d1d3e9c7f04457c2dd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 11 Sep 2026 21:16:13 +0200 Subject: [PATCH 2/3] fix: render rich markup in comments --- .../player/components/CommentListItems.kt | 7 +- .../player/components/CommentRichText.kt | 171 ++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt index f6ee514e..8bde0528 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt @@ -126,11 +126,12 @@ private fun ExpandableCommentText( onTimestampClick: (Long) -> Unit, ) { var expanded by remember(comment.text) { mutableStateOf(false) } - val needsTruncation = comment.text.length > COMMENT_COLLAPSE_CHARACTER_LIMIT || - comment.text.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT + val renderedText = remember(comment.text) { richMarkupPlainText(comment.text) } + val needsTruncation = renderedText.length > COMMENT_COLLAPSE_CHARACTER_LIMIT || + renderedText.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT Column { - LinkedText( + CommentRichText( text = comment.text, style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), linkColor = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt new file mode 100644 index 00000000..d00b5b1a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt @@ -0,0 +1,171 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +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.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +@Composable +internal fun CommentRichText( + text: String, + style: TextStyle, + linkColor: Color, + onUrlClick: (String) -> Unit, + onTimestampClick: (Long) -> Unit, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, +) { + val latestOnUrlClick = rememberUpdatedState(onUrlClick) + val latestOnTimestampClick = rememberUpdatedState(onTimestampClick) + val nodes = remember(text) { parseRichMarkup(text) } + val linkStyles = remember(linkColor) { + TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) + } + val highlightBackground = MaterialTheme.colorScheme.surfaceVariant + val formatStyles = remember(highlightBackground) { + mapOf( + RichMarkupFormat.Strong to SpanStyle(fontWeight = FontWeight.Bold), + RichMarkupFormat.Emphasized to SpanStyle(fontStyle = FontStyle.Italic), + RichMarkupFormat.Underline to SpanStyle(textDecoration = TextDecoration.Underline), + RichMarkupFormat.Strikethrough to SpanStyle(textDecoration = TextDecoration.LineThrough), + RichMarkupFormat.Code to SpanStyle(fontFamily = FontFamily.Monospace), + RichMarkupFormat.Keyboard to SpanStyle(fontFamily = FontFamily.Monospace), + RichMarkupFormat.Highlight to SpanStyle(background = highlightBackground), + ) + } + val annotated = remember(nodes, linkStyles, formatStyles) { + buildAnnotatedString { + appendMarkup( + nodes = nodes, + style = SpanStyle(), + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = true, + onUrlClick = latestOnUrlClick, + onTimestampClick = latestOnTimestampClick, + ) + } + } + Text( + text = annotated, + style = style, + modifier = modifier, + maxLines = maxLines, + overflow = overflow, + ) +} + +private fun AnnotatedString.Builder.appendMarkup( + nodes: List, + style: SpanStyle, + linkStyles: TextLinkStyles, + formatStyles: Map, + interactive: Boolean, + onUrlClick: State<(String) -> Unit>, + onTimestampClick: State<(Long) -> Unit>, +) { + nodes.forEach { node -> + when (node) { + is RichMarkupNode.Text -> appendText( + value = node.value, + style = style, + linkStyles = linkStyles, + interactive = interactive, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + is RichMarkupNode.Break -> append('\n') + is RichMarkupNode.Link -> withLink( + LinkAnnotation.Url( + url = node.href, + styles = linkStyles, + linkInteractionListener = { onUrlClick.value(node.href) }, + ), + ) { + appendMarkup( + nodes = node.children, + style = style, + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = false, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + } + is RichMarkupNode.Format -> appendMarkup( + nodes = node.children, + style = style.merge(formatStyles.getValue(node.format)), + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = interactive, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + } + } +} + +private fun AnnotatedString.Builder.appendText( + value: String, + style: SpanStyle, + linkStyles: TextLinkStyles, + interactive: Boolean, + onUrlClick: State<(String) -> Unit>, + onTimestampClick: State<(Long) -> Unit>, +) { + if (!interactive) { + withStyle(style) { append(value) } + return + } + withStyle(style) { + var cursor = 0 + interactiveTextRanges(value).forEach { range -> + append(value.substring(cursor, range.start)) + when (range) { + is InteractiveTextRange.Url -> withLink( + LinkAnnotation.Url( + url = range.value, + styles = linkStyles, + linkInteractionListener = { onUrlClick.value(range.value) }, + ), + ) { + append(value.substring(range.start, range.endExclusive)) + } + is InteractiveTextRange.Timestamp -> withLink( + LinkAnnotation.Clickable( + tag = range.positionMillis.toString(), + styles = linkStyles, + linkInteractionListener = { onTimestampClick.value(range.positionMillis) }, + ), + ) { + append(value.substring(range.start, range.endExclusive)) + } + } + cursor = range.endExclusive + } + append(value.substring(cursor)) + } +} From 080d7da3f963113a43441230bac7151256e9c9b8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 11 Sep 2026 21:17:17 +0200 Subject: [PATCH 3/3] build: publish mobile beta 1.8.0-beta.10 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 987624a2..ac08ab6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10809 - versionName = "1.8.0-beta.9" + versionCode = 10810 + versionName = "1.8.0-beta.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") }