From 72e23fded87f76f544745b1e64d8743b34007ab1 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 21:12:08 -0400 Subject: [PATCH] feat: add markdown renderer with CommonMark support --- app/build.gradle.kts | 2 + .../app/carddetail/MarkdownRenderer.kt | 53 ++++++ .../app/carddetail/MarkdownRendererTest.kt | 154 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 4 files changed, 212 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRenderer.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRendererTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 404976a..b67ce8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,8 @@ dependencies { implementation(libs.androidx.recyclerview) implementation(libs.androidx.swiperefreshlayout) implementation(libs.kotlinx.coroutines.android) + implementation(libs.commonmark) + implementation(libs.commonmark.ext.gfm.tables) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRenderer.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRenderer.kt new file mode 100644 index 0000000..70864bf --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRenderer.kt @@ -0,0 +1,53 @@ +package space.hackenslacker.kanbn4droid.app.carddetail + +import android.text.Spanned +import android.text.method.MovementMethod +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.core.text.HtmlCompat +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer + +class MarkdownRenderer( + private val parseToHtml: (String) -> String = defaultParseToHtml(), + private val htmlToSpanned: (String) -> Spanned = { html -> + HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + }, +) { + + fun render(markdown: String): Spanned { + return try { + val html = parseToHtml(markdown) + htmlToSpanned(html) + } catch (_: Exception) { + htmlToSpanned(markdown) + } + } + + companion object { + fun enableLinks(textView: TextView) { + enableLinks( + applyMovementMethod = { movementMethod -> textView.movementMethod = movementMethod }, + movementMethodProvider = { LinkMovementMethod.getInstance() }, + ) + } + + fun enableLinks( + applyMovementMethod: (MovementMethod) -> Unit, + movementMethodProvider: () -> MovementMethod = { LinkMovementMethod.getInstance() }, + ) { + applyMovementMethod(movementMethodProvider()) + } + + private fun defaultParseToHtml(): (String) -> String { + val parser = Parser.builder().build() + val renderer = HtmlRenderer.builder() + .escapeHtml(true) + .build() + return { markdown -> + val document = parser.parse(markdown) + renderer.render(document) + } + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRendererTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRendererTest.kt new file mode 100644 index 0000000..54a366f --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/MarkdownRendererTest.kt @@ -0,0 +1,154 @@ +package space.hackenslacker.kanbn4droid.app.carddetail + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownRendererTest { + + @Test + fun render_markdownBoldAndLink_returnsStyledSpanned() { + val renderer = MarkdownRenderer( + htmlToSpanned = { html -> + val spans = mutableListOf() + if (html.contains("bold")) { + spans += TestSpan.Bold + } + if (html.contains("Kan.bn")) { + spans += TestSpan.Link("https://kan.bn") + } + TestSpanned(html, spans) + }, + ) + + val result = renderer.render("This is **bold** and [Kan.bn](https://kan.bn)") + + val boldSpans = result.getSpans(0, result.length, TestSpan.Bold::class.java) + val linkSpans = result.getSpans(0, result.length, TestSpan.Link::class.java) + + assertTrue(boldSpans.isNotEmpty()) + assertTrue(linkSpans.any { it.url == "https://kan.bn" }) + } + + @Test + fun render_whenRenderingFails_returnsPlainTextFallback() { + val renderer = MarkdownRenderer( + parseToHtml = { throw IllegalStateException("boom") }, + htmlToSpanned = { html -> TestSpanned(html) }, + ) + + val result = renderer.render("**keep this literal**") + + assertEquals("**keep this literal**", result.toString()) + } + + @Test + fun render_rawHtml_isEscaped() { + val renderer = MarkdownRenderer( + htmlToSpanned = { html -> TestSpanned(html) }, + ) + + val result = renderer.render(" **ok**") + + assertTrue(result.toString().contains("<script>alert(1)</script>")) + } + + @Test + fun enableLinks_setsLinkMovementMethodOnPreviewTextWidget() { + var applied = false + val fakeMovementMethod = TestMovementMethod() + + MarkdownRenderer.enableLinks( + applyMovementMethod = { movementMethod -> + applied = movementMethod === fakeMovementMethod + }, + movementMethodProvider = { fakeMovementMethod }, + ) + + assertTrue(applied) + } + + private sealed interface TestSpan { + data object Bold : TestSpan + + data class Link(val url: String) : TestSpan + } + + private class TestMovementMethod : android.text.method.MovementMethod { + override fun initialize(widget: android.widget.TextView?, text: android.text.Spannable?) = Unit + + override fun onKeyDown( + widget: android.widget.TextView?, + text: android.text.Spannable?, + keyCode: Int, + event: android.view.KeyEvent?, + ): Boolean = false + + override fun onKeyUp( + widget: android.widget.TextView?, + text: android.text.Spannable?, + keyCode: Int, + event: android.view.KeyEvent?, + ): Boolean = false + + override fun onKeyOther( + view: android.widget.TextView?, + text: android.text.Spannable?, + event: android.view.KeyEvent?, + ): Boolean = false + + override fun onTakeFocus(widget: android.widget.TextView?, text: android.text.Spannable?, direction: Int) = Unit + + override fun onTrackballEvent( + widget: android.widget.TextView?, + text: android.text.Spannable?, + event: android.view.MotionEvent?, + ): Boolean = false + + override fun onTouchEvent( + widget: android.widget.TextView?, + text: android.text.Spannable?, + event: android.view.MotionEvent?, + ): Boolean = false + + override fun onGenericMotionEvent( + widget: android.widget.TextView?, + text: android.text.Spannable?, + event: android.view.MotionEvent?, + ): Boolean = false + + override fun canSelectArbitrarily(): Boolean = false + } + + private class TestSpanned( + private val raw: String, + private val spans: List = emptyList(), + ) : android.text.Spanned { + override val length: Int + get() = raw.length + + override fun get(index: Int): Char = raw[index] + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = raw.subSequence(startIndex, endIndex) + + override fun toString(): String = raw + + override fun getSpanStart(tag: Any): Int = if (spans.contains(tag)) 0 else -1 + + override fun getSpanEnd(tag: Any): Int = if (spans.contains(tag)) raw.length else -1 + + override fun getSpanFlags(tag: Any): Int = 0 + + override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit + + override fun getSpans(start: Int, end: Int, kind: Class): Array { + val filtered = spans.filter { kind.isInstance(it) }.map { requireNotNull(kind.cast(it)) } + @Suppress("UNCHECKED_CAST") + val array = java.lang.reflect.Array.newInstance(kind, filtered.size) as Array + filtered.forEachIndexed { index, element -> + array[index] = element + } + return array + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 807de98..1ad0891 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ lifecycle = "2.8.7" swiperefreshlayout = "1.1.0" recyclerview = "1.3.2" activity = "1.9.3" +commonmark = "0.22.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -35,6 +36,8 @@ androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", ve androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" } +commonmark-ext-gfm-tables = { group = "org.commonmark", name = "commonmark-ext-gfm-tables", version.ref = "commonmark" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }