From b9be125c9540c5bce1f4b21943c1edff029060b4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 10 Mar 2023 20:30:55 +0100 Subject: [PATCH] Show the difference between edited statuses (#3314) * Show the difference between edited statuses Diff each status against the previous version, comparing the different HTML as XML to produce a structured diff. Mark new content with ``, deleted content with ``. Convert these to styled spans in `ViewEditsAdapter`. * Update diffx to 1.1.1 Fixes issue with diffs splitting on accented characters * Style edited strings with Android spans Don't use HTML spans and try and format them, create real Android spans. Do this with a custom tag handler that can add custom spans that set the text paint appropriately. * Lint * Move colors in to theme_colors.xml * Draw a roundrect for the backoround, add start/end padding Make the background slightlysofter by drawing it as a roundrect. Make the spans easier to understand by padding the start/end of each one with the width of a " " character. This is visual only, the underlying text is not changed. * Catch exceptions when parsing XML * Move sorting in to Dispatchers.Default coroutine * Scope the loader type * Remove alpha --- app/build.gradle | 2 + .../viewthread/edits/ViewEditsAdapter.kt | 193 +++++++++++++++++- .../viewthread/edits/ViewEditsViewModel.kt | 144 ++++++++++++- .../tusky/util/StatusParsingHelper.kt | 6 +- .../main/res/values-night/theme_colors.xml | 3 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/theme_colors.xml | 3 + gradle/libs.versions.toml | 5 + 8 files changed, 349 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2ccfcebf2..685c6dbca 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -160,6 +160,8 @@ dependencies { implementation libs.bouncycastle implementation libs.unified.push + implementation libs.bundles.xmldiff + testImplementation libs.androidx.test.junit testImplementation libs.robolectric testImplementation libs.bundles.mockito diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 88396bceb..075a84e23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -1,7 +1,17 @@ package com.keylesspalace.tusky.components.viewthread.edits +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.Html +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ReplacementSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -30,6 +40,7 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.toViewData +import org.xml.sax.XMLReader class ViewEditsAdapter( private val edits: List, @@ -47,11 +58,11 @@ class ViewEditsAdapter( ): BindingHolder { val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.statusEditMediaPreview.clipToOutline = true + return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val edit = edits[position] val binding = holder.binding @@ -90,7 +101,11 @@ class ViewEditsAdapter( ) } - val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis) + val emojifiedText = edit + .content + .parseAsMastodonHtml(TuskyTagHandler(context)) + .emojify(edit.emojis, binding.statusEditContent, animateEmojis) + setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) if (edit.poll == null) { @@ -184,3 +199,177 @@ class ViewEditsAdapter( override fun getItemCount() = edits.size } + +/** + * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or + * deleted text. + */ +class TuskyTagHandler(val context: Context) : Html.TagHandler { + /** Class to mark the start of a span of deleted text */ + class Del + + /** Class to mark the start of a span of inserted text */ + class Ins + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + when (tag) { + DELETED_TEXT_EL -> { + if (opening) { + start(output as SpannableStringBuilder, Del()) + } else { + end( + output as SpannableStringBuilder, + Del::class.java, + DeletedTextSpan(context) + ) + } + } + INSERTED_TEXT_EL -> { + if (opening) { + start(output as SpannableStringBuilder, Ins()) + } else { + end( + output as SpannableStringBuilder, + Ins::class.java, + InsertedTextSpan(context) + ) + } + } + } + } + + /** @return the last span in [text] of type [kind], or null if that kind is not in text */ + private fun getLast(text: Spanned, kind: Class): Any? { + val spans = text.getSpans(0, text.length, kind) + return spans?.get(spans.size - 1) + } + + /** + * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. + */ + private fun start(text: SpannableStringBuilder, mark: Any) { + val len = text.length + text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) + } + + /** + * Set a [span] over the [text] most from the point recently marked with [mark] to the end + * of the text. + */ + private fun end(text: SpannableStringBuilder, mark: Class, span: Any) { + val len = text.length + val obj = getLast(text, mark) + val where = text.getSpanStart(obj) + text.removeSpan(obj) + if (where != len) { + text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + /** + * A span that draws text with additional padding at the start/end of the text. The padding + * is the width of [separator]. + * + * Note: The separator string is not included in the final text, so it will not be included + * if the user cuts or copies the text. + */ + open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() { + /** The width of the separator string, used as padding */ + var paddingWidth = 0f + + /** Measured width of the span */ + var spanWidth = 0f + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + paddingWidth = paint.measureText(separator, 0, separator.length) + spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end) + return spanWidth.toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint) + } + } + + /** Span that signifies deleted text */ + class DeletedTextSpan(context: Context) : LRPaddedSpan() { + private val bgPaint = Paint() + val radius: Float + + init { + bgPaint.color = context.getColor(R.color.view_edits_background_delete) + radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius) + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint) + paint.isStrikeThruText = true + super.draw(canvas, text, start, end, x, top, y, bottom, paint) + } + } + + /** Span that signifies inserted text */ + class InsertedTextSpan(context: Context) : LRPaddedSpan() { + val bgPaint = Paint() + val radius: Float + + init { + bgPaint.color = context.getColor(R.color.view_edits_background_insert) + radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius) + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint) + paint.typeface = DEFAULT_BOLD + super.draw(canvas, text, start, end, x, top, y, bottom, paint) + } + } + + companion object { + /** XML element to represent text that has been deleted */ + // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler + // won't be called for it. + const val DELETED_TEXT_EL = "tusky-del" + + /** XML element to represet text that has been inserted */ + // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler + // won't be called for it. + const val INSERTED_TEXT_EL = "tusky-ins" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index c5959d262..6dc0e25fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -18,17 +18,32 @@ package com.keylesspalace.tusky.components.viewthread.edits import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.pageseeder.diffx.api.LoadingException +import org.pageseeder.diffx.api.Operator +import org.pageseeder.diffx.config.DiffConfig +import org.pageseeder.diffx.config.TextGranularity +import org.pageseeder.diffx.config.WhiteSpaceProcessing +import org.pageseeder.diffx.core.OptimisticXMLProcessor +import org.pageseeder.diffx.format.XMLDiffOutput +import org.pageseeder.diffx.load.SAXLoader +import org.pageseeder.diffx.token.XMLToken +import org.pageseeder.diffx.token.XMLTokenType +import org.pageseeder.diffx.token.impl.SpaceToken +import org.pageseeder.diffx.xml.NamespaceSet +import org.pageseeder.xmlwriter.XML.NamespaceAware +import org.pageseeder.xmlwriter.XMLStringWriter import javax.inject.Inject -class ViewEditsViewModel @Inject constructor( - private val api: MastodonApi -) : ViewModel() { +class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) val uiState: StateFlow = _uiState.asStateFlow() @@ -45,8 +60,59 @@ class ViewEditsViewModel @Inject constructor( viewModelScope.launch { api.statusEdits(statusId).fold( { edits -> - val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() - _uiState.value = EditsUiState.Success(sortedEdits) + // Diff each status' content against the previous version, producing new + // content with additional `ins` or `del` elements marking inserted or + // deleted content. + // + // This can be CPU intensive depending on the number of edits and the size + // of each, so don't run this on Dispatchers.Main. + viewModelScope.launch(Dispatchers.Default) { + val sortedEdits = edits.sortedBy { it.createdAt } + .reversed() + .toMutableList() + + SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver") + val loader = SAXLoader() + loader.config = DiffConfig( + false, + WhiteSpaceProcessing.PRESERVE, + TextGranularity.SPACE_WORD + ) + val processor = OptimisticXMLProcessor() + processor.setCoalesce(true) + val output = HtmlDiffOutput() + + try { + // The XML processor expects `br` to be closed + var currentContent = + loader.load(sortedEdits[0].content.replace("
", "
")) + var previousContent = + loader.load(sortedEdits[1].content.replace("
", "
")) + + for (i in 1 until sortedEdits.size) { + processor.diff(previousContent, currentContent, output) + sortedEdits[i - 1] = sortedEdits[i - 1].copy( + content = output.xml.toString() + ) + + if (i < sortedEdits.size - 1) { + currentContent = previousContent + previousContent = loader.load( + sortedEdits[i + 1].content.replace( + "
", + "
" + ) + ) + } + } + _uiState.value = EditsUiState.Success(sortedEdits) + } catch (_: LoadingException) { + // Something failed parsing the XML from the server. Rather than + // show an error just return the sorted edits so the user can at + // least visually scan the differences. + _uiState.value = EditsUiState.Success(sortedEdits) + } + } }, { throwable -> _uiState.value = EditsUiState.Error(throwable) @@ -54,6 +120,10 @@ class ViewEditsViewModel @Inject constructor( ) } } + + companion object { + const val TAG = "ViewEditsViewModel" + } } sealed interface EditsUiState { @@ -68,3 +138,67 @@ sealed interface EditsUiState { val edits: List ) : EditsUiState } + +/** + * Add elements wrapping inserted or deleted content. + */ +class HtmlDiffOutput : XMLDiffOutput { + /** XML Output */ + lateinit var xml: XMLStringWriter + private set + + override fun start() { + xml = XMLStringWriter(NamespaceAware.Yes) + } + + override fun handle(operator: Operator, token: XMLToken) { + if (operator.isEdit) { + handleEdit(operator, token) + } else { + token.toXML(xml) + } + } + + override fun end() { + xml.flush() + } + + override fun setWriteXMLDeclaration(show: Boolean) { + // This space intentionally left blank + } + + override fun setNamespaces(namespaces: NamespaceSet?) { + // This space intentionally left blank + } + + private fun handleEdit(operator: Operator, token: XMLToken) { + if (token == SpaceToken.NEW_LINE) { + if (operator == Operator.INS) { + token.toXML(xml) + } + return + } + when (token.type) { + XMLTokenType.START_ELEMENT -> token.toXML(xml) + XMLTokenType.END_ELEMENT -> token.toXML(xml) + XMLTokenType.TEXT -> { + // wrap the characters in a element + when (operator) { + Operator.DEL -> DELETED_TEXT_EL + Operator.INS -> INSERTED_TEXT_EL + else -> null + }?.let { + xml.openElement(it, false) + } + token.toXML(xml) + xml.closeElement() + } + else -> { + // Only include inserted content + if (operator === Operator.INS) { + token.toXML(xml) + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index 2ac4782c3..18c95c8ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -16,6 +16,7 @@ @file:JvmName("StatusParsingHelper") package com.keylesspalace.tusky.util +import android.text.Html.TagHandler import android.text.SpannableStringBuilder import android.text.Spanned import androidx.core.text.parseAsHtml @@ -23,12 +24,13 @@ import androidx.core.text.parseAsHtml /** * parse a String containing html from the Mastodon api to Spanned */ -fun String.parseAsMastodonHtml(): Spanned { +@JvmOverloads +fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { return this.replace("
", "
 ") .replace("
", "
 ") .replace("
", "
 ") .replace(" ", "  ") - .parseAsHtml() + .parseAsHtml(tagHandler = tagHandler) /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which * most status contents do, so it should be trimmed. */ .trimTrailingWhitespace() diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml index 348652a72..cb57cf8d8 100644 --- a/app/src/main/res/values-night/theme_colors.xml +++ b/app/src/main/res/values-night/theme_colors.xml @@ -28,4 +28,7 @@ @color/white @color/tusky_grey_10 + + #00731B + #DF0000 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index b6f88d094..abba099e1 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -61,4 +61,6 @@ 4dp 1dp + + 4dp diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml index 2f5da6b81..32f2727fd 100644 --- a/app/src/main/res/values/theme_colors.xml +++ b/app/src/main/res/values/theme_colors.xml @@ -28,4 +28,7 @@ @color/tusky_grey_20 @color/white + + #CCFFD8 + #FFC0C0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a81366661..72bb06678 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ bouncycastle = "1.70" conscrypt = "2.5.2" coroutines = "1.6.4" dagger = "2.45" +diffx = "1.1.1" emoji2 = "1.2.0" espresso = "3.5.1" filemoji-compat = "3.2.7" @@ -50,6 +51,7 @@ sparkbutton = "4.1.0" truth = "1.1.3" turbine = "0.12.1" unified-push = "2.1.1" +xmlwriter = "1.0.4" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -99,6 +101,7 @@ dagger-android-processor = { module = "com.google.dagger:dagger-android-processo dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } dagger-core = { module = "com.google.dagger:dagger", version.ref = "dagger" } +diffx = { module = "org.pageseeder.diffx:pso-diffx", version.ref = "diffx" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } filemojicompat-core = { module = "de.c1710:filemojicompat", version.ref = "filemoji-compat" } filemojicompat-defaults = { module = "de.c1710:filemojicompat-defaults", version.ref = "filemoji-compat" } @@ -134,6 +137,7 @@ sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "spar truth = { module = "com.google.truth:truth", version.ref = "truth" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" } +xmlwriter = { module = "org.pageseeder.xmlwriter:pso-xmlwriter", version.ref = "xmlwriter" } [bundles] androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-browser", "androidx-swiperefreshlayout", @@ -153,3 +157,4 @@ okhttp = ["okhttp-core", "okhttp-logging-interceptor"] retrofit = ["retrofit-core", "retrofit-converter-gson", "retrofit-adapter-rxjava3"] room = ["androidx-room-ktx", "androidx-room-paging"] rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"] +xmldiff = ["diffx", "xmlwriter"]