From 78ecbfe9e932b04c6399af678b5789093839660e Mon Sep 17 00:00:00 2001 From: Jens Kat Date: Sat, 22 Sep 2018 17:54:57 +0200 Subject: [PATCH 1/2] Downgrade the html5 `
` tags with html4 ``

The textview supports formatting of a few built-in html tags,
`tt` (teletype) is deprecated in html5, but for android this is still implemented

Advantages of this `tt` formatting is that it respects newlines and whitespace,
which makes code formatted comments on Hackernews better readable.

Fixes #883
---
 .../github/hidroh/materialistic/AppUtils.java | 23 ++++++++++++--
 .../hidroh/materialistic/AppUtilsTest.java    | 30 +++++++++++++++++++
 2 files changed, 50 insertions(+), 3 deletions(-)

diff --git a/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java b/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java
index df5479af4..752bdff9a 100644
--- a/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java
+++ b/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java
@@ -184,18 +184,35 @@ public static CharSequence fromHtml(String htmlText, boolean compact) {
         if (TextUtils.isEmpty(htmlText)) {
             return null;
         }
-        CharSequence spanned;
+        final String replacedText = replacePreCode(htmlText);
+        final CharSequence spanned;
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
             //noinspection InlinedApi
-            spanned = Html.fromHtml(htmlText, compact ?
+            spanned = Html.fromHtml(replacedText, compact ?
                     Html.FROM_HTML_MODE_COMPACT : Html.FROM_HTML_MODE_LEGACY);
         } else {
             //noinspection deprecation
-            spanned = Html.fromHtml(htmlText);
+            spanned = Html.fromHtml(replacedText);
         }
         return trim(spanned);
     }
 
+    /**
+     * Replaces occurrences of pre and code tags with the Html.fromHtml supported tt tag.
+     * If the pre/code block contains pre code, then we html encode the pre and code tags.
+     *
+     * @param htmlText The text that possibly contains the tags pre and code
+     * @return The text with tt tags
+     */
+    static String replacePreCode(final String htmlText) {
+        return htmlText
+                .replaceAll("
((?s).*?)
", "$1") + .replaceAll("
", "<pre>")
+                .replaceAll("", "<code>")
+                .replaceAll("
", "</pre>") + .replaceAll("", "</code>"); + } + public static Intent makeSendIntentChooser(Context context, Uri data) { // use ACTION_SEND_MULTIPLE instead of ACTION_SEND to filter out // share receivers that accept only EXTRA_TEXT but not EXTRA_STREAM diff --git a/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java b/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java index feacd1935..c1d97c2bc 100644 --- a/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java +++ b/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java @@ -10,7 +10,9 @@ import android.support.design.widget.AppBarLayout; import android.support.design.widget.FloatingActionButton; import android.support.v4.content.LocalBroadcastManager; +import android.text.SpannedString; import android.text.format.DateUtils; +import android.text.style.TypefaceSpan; import android.view.ContextThemeWrapper; import android.view.MotionEvent; import android.view.View; @@ -239,6 +241,34 @@ public void testTrimHtmlWhitespaces() { assertThat(textView).hasTextString("paragraph"); } + @Test + public void testPreformattedTextHasMonospaceTypeface() { + TextView textView = new TextView(context); + textView.setText(AppUtils.fromHtml("
val x = myCode()
")); + assertThat(textView).hasTextString("val x = myCode()"); + + SpannedString view = (SpannedString) textView.getText(); + TypefaceSpan[] spans = view.getSpans(0, view.length(), TypefaceSpan.class); + assertThat(spans[0].getFamily()).isEqualTo("monospace"); + } + + @Test + public void testReplacesPreCodeTagsWithTT() { + String oneCodeBlock = "
val x = myCode()

More Text


"; + String multipleCodeBlocks = "
val x = myCode() \n val y = someMoreCode()

some more text

val x = myCode()
"; + String nestedCodeBlocks = "
val x = myCode() \n # now some nested codeblocks\n 
val x = 
nothing to see here
"; + + String oneCodeBlockResult = AppUtils.replacePreCode(oneCodeBlock); + String multipleCodeBlocksResult = AppUtils.replacePreCode(multipleCodeBlocks); + String nestedCodeBlockResult = AppUtils.replacePreCode(nestedCodeBlocks); + + assertThat(oneCodeBlockResult).isEqualTo("val x = myCode()

More Text


"); + assertThat(multipleCodeBlocksResult).isEqualTo("val x = myCode() \n val y = someMoreCode()

some more text

val x = myCode()"); + // todo this is a bug, the closing tag is matched eagerly, + // however it's not worse than before, where pre code tags were 'eaten'. + assertThat(nestedCodeBlockResult).doesNotMatch("val x = myCode() \n # now some nested codeblocks\n <pre><code>val x = <pre><code>nothing to see here</code></pre></code></pre>"); + } + @Test public void testOpenExternalUrlNoConnection() { shadowOf((ConnectivityManager) context From 877dc3289a3c54926a0b5e9b4b1366c8c73d95b7 Mon Sep 17 00:00:00 2001 From: Jens Kat Date: Sun, 23 Sep 2018 10:25:14 +0200 Subject: [PATCH 2/2] Implement code highlighting with custom code `Html.TagHandler` Advantage over regex is that this handling only kicks in the moment the text contains such a tag. --- .../github/hidroh/materialistic/AppUtils.java | 26 ++----- .../materialistic/HtmlCodeTagHandler.java | 71 +++++++++++++++++++ .../hidroh/materialistic/AppUtilsTest.java | 24 +++---- 3 files changed, 87 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/io/github/hidroh/materialistic/HtmlCodeTagHandler.java diff --git a/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java b/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java index 752bdff9a..72268fc59 100644 --- a/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java +++ b/app/src/main/java/io/github/hidroh/materialistic/AppUtils.java @@ -92,6 +92,7 @@ public class AppUtils { public static final int HOT_FACTOR = 3; private static final String HOST_ITEM = "item"; private static final String HOST_USER = "user"; + private static final HtmlCodeTagHandler CODE_TAG_HANDLER = new HtmlCodeTagHandler(); public static void openWebUrlExternal(Context context, @Nullable WebItem item, String url, @Nullable CustomTabsSession session) { @@ -184,35 +185,20 @@ public static CharSequence fromHtml(String htmlText, boolean compact) { if (TextUtils.isEmpty(htmlText)) { return null; } - final String replacedText = replacePreCode(htmlText); final CharSequence spanned; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //noinspection InlinedApi - spanned = Html.fromHtml(replacedText, compact ? - Html.FROM_HTML_MODE_COMPACT : Html.FROM_HTML_MODE_LEGACY); + spanned = Html.fromHtml(htmlText, compact ? + Html.FROM_HTML_MODE_COMPACT : Html.FROM_HTML_MODE_LEGACY, + null, + CODE_TAG_HANDLER); } else { //noinspection deprecation - spanned = Html.fromHtml(replacedText); + spanned = Html.fromHtml(htmlText, null, CODE_TAG_HANDLER); } return trim(spanned); } - /** - * Replaces occurrences of pre and code tags with the Html.fromHtml supported tt tag. - * If the pre/code block contains pre code, then we html encode the pre and code tags. - * - * @param htmlText The text that possibly contains the tags pre and code - * @return The text with tt tags - */ - static String replacePreCode(final String htmlText) { - return htmlText - .replaceAll("
((?s).*?)
", "$1") - .replaceAll("
", "<pre>")
-                .replaceAll("", "<code>")
-                .replaceAll("
", "</pre>") - .replaceAll("", "</code>"); - } - public static Intent makeSendIntentChooser(Context context, Uri data) { // use ACTION_SEND_MULTIPLE instead of ACTION_SEND to filter out // share receivers that accept only EXTRA_TEXT but not EXTRA_STREAM diff --git a/app/src/main/java/io/github/hidroh/materialistic/HtmlCodeTagHandler.java b/app/src/main/java/io/github/hidroh/materialistic/HtmlCodeTagHandler.java new file mode 100644 index 000000000..c49a4090f --- /dev/null +++ b/app/src/main/java/io/github/hidroh/materialistic/HtmlCodeTagHandler.java @@ -0,0 +1,71 @@ +package io.github.hidroh.materialistic; + +import android.text.Editable; +import android.text.Html; +import android.text.Spannable; +import android.text.Spanned; +import android.text.style.TypefaceSpan; + +import org.xml.sax.XMLReader; + +/** + * TagHandler that 'knows' how to handle code tags. + * This handler can be used in the Html.fromHtml method to support formatting of code tags using the monospace typeface. + * + * Hackernews returns <pre><code> blocks, but we are only interested in the inner code. + * The pre tag is then ignored by the Html.fromHtml default tag handler. + */ +public class HtmlCodeTagHandler implements Html.TagHandler { + @Override + public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { + if (tag.equalsIgnoreCase("code")) { + if (opening) { + start(output, new Monospace()); + } else { + end(output, Monospace.class, new TypefaceSpan("monospace")); + } + } + } + + // This is copied from android.text.HtmlToSpannedConverter#start + private static void start(Editable text, Object mark) { + int len = text.length(); + text.setSpan(mark, len, len, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + + // This is copied from android.text.HtmlToSpannedConverter#end + private static void end(Editable text, Class kind, Object repl) { + Object obj = getLast(text, kind); + if (obj != null) { + setSpanFromMark(text, obj, repl); + } + } + + // This is copied from android.text.HtmlToSpannedConverter#setSpanFromMark + private static void setSpanFromMark(Spannable text, Object mark, Object... spans) { + int where = text.getSpanStart(mark); + text.removeSpan(mark); + int len = text.length(); + if (where != len) { + for (Object span : spans) { + text.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + // This is copied from android.text.HtmlToSpannedConverter#getLast + private static T getLast(Spanned text, Class kind) { + /* + * This knows that the last returned object from getSpans() + * will be the most recently added. + */ + T[] objs = text.getSpans(0, text.length(), kind); + if (objs.length == 0) { + return null; + } else { + return objs[objs.length - 1]; + } + } + // This is copied from android.text.HtmlToSpannedConverter.Monospace + private static class Monospace {} +} diff --git a/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java b/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java index c1d97c2bc..e824e8864 100644 --- a/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java +++ b/app/src/test/java/io/github/hidroh/materialistic/AppUtilsTest.java @@ -249,24 +249,20 @@ public void testPreformattedTextHasMonospaceTypeface() { SpannedString view = (SpannedString) textView.getText(); TypefaceSpan[] spans = view.getSpans(0, view.length(), TypefaceSpan.class); + assertThat(spans.length).isEqualTo(1); assertThat(spans[0].getFamily()).isEqualTo("monospace"); } @Test - public void testReplacesPreCodeTagsWithTT() { - String oneCodeBlock = "
val x = myCode()

More Text


"; - String multipleCodeBlocks = "
val x = myCode() \n val y = someMoreCode()

some more text

val x = myCode()
"; - String nestedCodeBlocks = "
val x = myCode() \n # now some nested codeblocks\n 
val x = 
nothing to see here
"; - - String oneCodeBlockResult = AppUtils.replacePreCode(oneCodeBlock); - String multipleCodeBlocksResult = AppUtils.replacePreCode(multipleCodeBlocks); - String nestedCodeBlockResult = AppUtils.replacePreCode(nestedCodeBlocks); - - assertThat(oneCodeBlockResult).isEqualTo("val x = myCode()

More Text


"); - assertThat(multipleCodeBlocksResult).isEqualTo("val x = myCode() \n val y = someMoreCode()

some more text

val x = myCode()"); - // todo this is a bug, the closing tag is matched eagerly, - // however it's not worse than before, where pre code tags were 'eaten'. - assertThat(nestedCodeBlockResult).doesNotMatch("val x = myCode() \n # now some nested codeblocks\n <pre><code>val x = <pre><code>nothing to see here</code></pre></code></pre>"); + public void testPreformattedTextHasMultipleMonospaceTypeface() { + TextView textView = new TextView(context); + textView.setText(AppUtils.fromHtml("
val x = myCode()

some more text

val y = myCode()

And more text")); + + SpannedString view = (SpannedString) textView.getText(); + TypefaceSpan[] spans = view.getSpans(0, view.length(), TypefaceSpan.class); + assertThat(spans.length).isEqualTo(2); + assertThat(spans[0].getFamily()).isEqualTo("monospace"); + assertThat(spans[1].getFamily()).isEqualTo("monospace"); } @Test