From 3350c11980d40e117f711e6b27116b9fe3ae6a0e Mon Sep 17 00:00:00 2001 From: M66B Date: Sun, 18 Feb 2024 15:28:17 +0100 Subject: [PATCH] Experiment: markdown support --- ATTRIBUTION.md | 1 + app/build.gradle | 14 +- app/src/main/assets/ATTRIBUTION.md | 1 + .../eu/faircode/email/FragmentCompose.java | 120 ++++++++++++++---- .../res/drawable/twotone_data_array_24.xml | 13 ++ app/src/main/res/menu/menu_compose.xml | 7 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 app/src/main/res/drawable/twotone_data_array_24.xml diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d12820378f..bd537e5e94 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -55,3 +55,4 @@ FairEmail uses parts or all of: * [AG Filters Registry](https://github.com/AdguardTeam/FiltersRegistry). [GNU Lesser General Public License Version 3](https://github.com/AdguardTeam/FiltersRegistry/blob/master/LICENSE). * [Certificate transparency for Android and JVM](https://github.com/appmattus/certificatetransparency). Copyright 2023 Appmattus Limited. [Apache License 2.0](https://github.com/appmattus/certificatetransparency/blob/main/LICENSE.md). * [ZXing](https://github.com/zxing/zxing). Copyright (C) 2014 ZXing authors. [Apache License 2.0](https://github.com/zxing/zxing/blob/master/LICENSE). +* [https://github.com/vsch/flexmark-java](flexmark-java). Copyright (c) 2015-2016, Atlassian Pty Ltd. Copyright (c) 2016-2018, Vladimir Schneider. [BSD-2-Clause license](https://github.com/vsch/flexmark-java/blob/master/LICENSE.txt). diff --git a/app/build.gradle b/app/build.gradle index f9c5b55243..af52775720 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -134,7 +134,11 @@ android { 'LICENSE-2.0.txt', 'RELEASE.txt', 'DebugProbesKt.bin', - 'font_metrics.properties' + 'font_metrics.properties', + 'META-INF/LICENSE-LGPL-2.1.txt', + 'META-INF/LICENSE-LGPL-3.txt', + 'META-INF/LICENSE-W3C-TEST', + 'META-INF/DEPENDENCIES' ] } } @@ -557,6 +561,7 @@ dependencies { def vcard_version = "0.12.1" def relinker_version = "1.4.5" def markwon_version = "4.6.2" + def flexmark_version = "0.64.8" def bouncycastle_version = "1.77" def colorpicker_version = "0.0.15" def overscroll_version = "1.1.1" @@ -765,6 +770,13 @@ dependencies { // https://mvnrepository.com/artifact/io.noties.markwon/core implementation "io.noties.markwon:core:$markwon_version" implementation "io.noties.markwon:html:$markwon_version" + implementation "io.noties.markwon:editor:$markwon_version" + + // https://github.com/vsch/flexmark-java + // https://mvnrepository.com/artifact/com.vladsch.flexmark/flexmark + //implementation "com.vladsch.flexmark:flexmark:$flexmark_version" + implementation "com.vladsch.flexmark:flexmark-ext-tables:$flexmark_version" + implementation "com.vladsch.flexmark:flexmark-html2md-converter:$flexmark_version" // // https://github.com/QuadFlask/colorpicker //implementation "com.github.QuadFlask:colorpicker:$colorpicker_version" diff --git a/app/src/main/assets/ATTRIBUTION.md b/app/src/main/assets/ATTRIBUTION.md index d12820378f..bd537e5e94 100644 --- a/app/src/main/assets/ATTRIBUTION.md +++ b/app/src/main/assets/ATTRIBUTION.md @@ -55,3 +55,4 @@ FairEmail uses parts or all of: * [AG Filters Registry](https://github.com/AdguardTeam/FiltersRegistry). [GNU Lesser General Public License Version 3](https://github.com/AdguardTeam/FiltersRegistry/blob/master/LICENSE). * [Certificate transparency for Android and JVM](https://github.com/appmattus/certificatetransparency). Copyright 2023 Appmattus Limited. [Apache License 2.0](https://github.com/appmattus/certificatetransparency/blob/main/LICENSE.md). * [ZXing](https://github.com/zxing/zxing). Copyright (C) 2014 ZXing authors. [Apache License 2.0](https://github.com/zxing/zxing/blob/master/LICENSE). +* [https://github.com/vsch/flexmark-java](flexmark-java). Copyright (c) 2015-2016, Atlassian Pty Ltd. Copyright (c) 2016-2018, Vladimir Schneider. [BSD-2-Clause license](https://github.com/vsch/flexmark-java/blob/master/LICENSE.txt). diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 11c4f312c9..0830e685e7 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -136,6 +136,12 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.LabelVisibilityMode; import com.google.android.material.snackbar.Snackbar; +import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; +import com.vladsch.flexmark.ext.tables.TablesExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.MutableDataSet; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.cert.jcajce.JcaCertStore; @@ -227,6 +233,9 @@ import javax.mail.util.ByteArrayDataSource; import biweekly.ICalendar; import biweekly.component.VEvent; import biweekly.property.Organizer; +import io.noties.markwon.Markwon; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; public class FragmentCompose extends FragmentBase { private enum State {NONE, LOADING, LOADED} @@ -280,6 +289,7 @@ public class FragmentCompose extends FragmentBase { private ContentResolver resolver; private AdapterAttachment adapter; + private MarkwonEditorTextWatcher markwonWatcher; private boolean autoscroll_editor; private int compose_color; @@ -291,6 +301,7 @@ public class FragmentCompose extends FragmentBase { private boolean style = false; private boolean media = true; private boolean compact = false; + private boolean markdown = false; private int zoom = 0; private boolean nav_color; private boolean lt_enabled; @@ -1119,6 +1130,19 @@ public class FragmentCompose extends FragmentBase { invalidateOptionsMenu(); Helper.setViewsEnabled(view, false); + // https://noties.io/Markwon/docs/v4/editor/ + try { + final Markwon markwon = Markwon.create(getContext()); + final MarkwonEditor editor = MarkwonEditor.create(markwon); + markwonWatcher = MarkwonEditorTextWatcher.withPreRender( + editor, + Helper.getParallelExecutor(), + etBody); + } catch (Throwable ex) { + Log.e(ex); + markwonWatcher = null; + } + final DB db = DB.getInstance(getContext()); SimpleCursorAdapter cadapter = new SimpleCursorAdapter( @@ -1936,6 +1960,7 @@ public class FragmentCompose extends FragmentBase { menu.findItem(R.id.menu_style).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_media).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_compact).setEnabled(state == State.LOADED); + menu.findItem(R.id.menu_markdown).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_contact_group).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_manage_android_contacts).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_manage_local_contacts).setEnabled(state == State.LOADED); @@ -1992,6 +2017,7 @@ public class FragmentCompose extends FragmentBase { boolean send_chips = prefs.getBoolean("send_chips", true); boolean send_dialog = prefs.getBoolean("send_dialog", true); boolean image_dialog = prefs.getBoolean("image_dialog", true); + boolean experiments = prefs.getBoolean("experiments", false); menu.findItem(R.id.menu_save_drafts).setChecked(save_drafts); menu.findItem(R.id.menu_send_chips).setChecked(send_chips); @@ -2000,6 +2026,8 @@ public class FragmentCompose extends FragmentBase { menu.findItem(R.id.menu_style).setChecked(style); menu.findItem(R.id.menu_media).setChecked(media); menu.findItem(R.id.menu_compact).setChecked(compact); + menu.findItem(R.id.menu_markdown).setChecked(markdown); + menu.findItem(R.id.menu_markdown).setVisible(experiments); View image = media_bar.findViewById(R.id.menu_image); if (image != null) @@ -2083,6 +2111,9 @@ public class FragmentCompose extends FragmentBase { } else if (itemId == R.id.menu_compact) { onMenuCompact(); return true; + } else if (itemId == R.id.menu_markdown) { + onMenuMarkdown(); + return true; } else if (itemId == R.id.menu_contact_group) { onMenuContactGroup(); return true; @@ -2315,6 +2346,11 @@ public class FragmentCompose extends FragmentBase { setCompact(compact); } + private void onMenuMarkdown() { + markdown = !markdown; + onAction(R.id.menu_save, "Markdown"); + } + private void setCompact(boolean compact) { bottom_navigation.setLabelVisibilityMode(compact ? LabelVisibilityMode.LABEL_VISIBILITY_UNLABELED @@ -4990,6 +5026,7 @@ public class FragmentCompose extends FragmentBase { args.putString("subject", etSubject.getText().toString().trim()); args.putCharSequence("loaded", (Spanned) etBody.getTag()); args.putCharSequence("spanned", etBody.getText()); + args.putBoolean("markdown", markdown); args.putBoolean("signature", cbSignature.isChecked()); args.putBoolean("empty", isEmpty()); args.putBoolean("notext", notext); @@ -6171,6 +6208,9 @@ public class FragmentCompose extends FragmentBase { Elements ref = doc.select("div[fairemail=reference]"); ref.remove(); + boolean markdown = Boolean.parseBoolean(doc.body().attr("markdown")); + args.putBoolean("markdown", markdown); + File refFile = data.draft.getRefFile(context); if (refFile.exists()) { ref.html(Helper.readText(refFile)); @@ -6243,6 +6283,7 @@ public class FragmentCompose extends FragmentBase { working = data.draft.id; dsn = (data.draft.dsn != null && !EntityMessage.DSN_NONE.equals(data.draft.dsn)); encrypt = data.draft.ui_encrypt; + markdown = args.getBoolean("markdown"); invalidateOptionsMenu(); subject = data.draft.subject; @@ -6665,6 +6706,7 @@ public class FragmentCompose extends FragmentBase { String subject = args.getString("subject"); Spanned loaded = (Spanned) args.getCharSequence("loaded"); Spanned spanned = (Spanned) args.getCharSequence("spanned"); + boolean markdown = args.getBoolean("markdown"); boolean signature = args.getBoolean("signature"); boolean empty = args.getBoolean("empty"); boolean notext = args.getBoolean("notext"); @@ -6673,7 +6715,22 @@ public class FragmentCompose extends FragmentBase { boolean silent = extras.getBoolean("silent"); boolean dirty = false; - String body = HtmlHelper.toHtml(spanned, context); + String body; + if (markdown) { + MutableDataSet options = new MutableDataSet(); + options.set(Parser.EXTENSIONS, Arrays.asList( + TablesExtension.create(), + StrikethroughExtension.create())); + Parser parser = Parser.builder(options).build(); + HtmlRenderer renderer = HtmlRenderer.builder(options).build(); + String html = renderer.render(parser.parse(spanned.toString())); + + Document doc = JsoupEx.parse(html); + doc.body().attr("markdown", "true"); + body = doc.html(); + } else + body = HtmlHelper.toHtml(spanned, context); + EntityMessage draft; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); @@ -7581,12 +7638,15 @@ public class FragmentCompose extends FragmentBase { Bundle args = new Bundle(); args.putLong("id", draft.id); args.putBoolean("show_images", show_images); + args.putBoolean("markdown", markdown); new SimpleTask() { @Override protected void onPreExecute(Bundle args) { // Needed to get width for images grpBody.setVisibility(View.VISIBLE); + if (markwonWatcher != null) + etBody.removeTextChangedListener(markwonWatcher); } @Override @@ -7602,12 +7662,18 @@ public class FragmentCompose extends FragmentBase { Helper.setViewsEnabled(view, true); invalidateOptionsMenu(); + + if (markdown && markwonWatcher != null) { + etBody.addTextChangedListener(markwonWatcher); + markwonWatcher.afterTextChanged(etBody.getText()); + } } @Override protected Spanned[] onExecute(final Context context, Bundle args) throws Throwable { final long id = args.getLong("id"); final boolean show_images = args.getBoolean("show_images", false); + final boolean markdown = args.getBoolean("markdown", false); int colorPrimary = Helper.resolveColor(context, androidx.appcompat.R.attr.colorPrimary); final int colorBlockquote = Helper.resolveColor(context, R.attr.colorBlockquote, colorPrimary); @@ -7624,35 +7690,41 @@ public class FragmentCompose extends FragmentBase { Elements ref = doc.select("div[fairemail=reference]"); ref.remove(); - HtmlHelper.clearAnnotations(doc); // Legacy left-overs + Spanned spannedBody; + if (markdown) { + MutableDataSet options = new MutableDataSet(); + spannedBody = new SpannableStringBuilder(FlexmarkHtmlConverter.builder(options).build().convert(doc.html())); + } else { + HtmlHelper.clearAnnotations(doc); // Legacy left-overs - doc = HtmlHelper.sanitizeCompose(context, doc.html(), true); + doc = HtmlHelper.sanitizeCompose(context, doc.html(), true); - Spanned spannedBody = HtmlHelper.fromDocument(context, doc, new HtmlHelper.ImageGetterEx() { - @Override - public Drawable getDrawable(Element element) { - return ImageHelper.decodeImage(context, - id, element, true, zoom, 1.0f, etBody); + spannedBody = HtmlHelper.fromDocument(context, doc, new HtmlHelper.ImageGetterEx() { + @Override + public Drawable getDrawable(Element element) { + return ImageHelper.decodeImage(context, + id, element, true, zoom, 1.0f, etBody); + } + }, null); + + SpannableStringBuilder bodyBuilder = new SpannableStringBuilderEx(spannedBody); + QuoteSpan[] bodySpans = bodyBuilder.getSpans(0, bodyBuilder.length(), QuoteSpan.class); + for (QuoteSpan quoteSpan : bodySpans) { + QuoteSpan q; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + q = new QuoteSpan(colorBlockquote); + else + q = new QuoteSpan(colorBlockquote, quoteStripe, quoteGap); + bodyBuilder.setSpan(q, + bodyBuilder.getSpanStart(quoteSpan), + bodyBuilder.getSpanEnd(quoteSpan), + bodyBuilder.getSpanFlags(quoteSpan)); + bodyBuilder.removeSpan(quoteSpan); } - }, null); - SpannableStringBuilder bodyBuilder = new SpannableStringBuilderEx(spannedBody); - QuoteSpan[] bodySpans = bodyBuilder.getSpans(0, bodyBuilder.length(), QuoteSpan.class); - for (QuoteSpan quoteSpan : bodySpans) { - QuoteSpan q; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) - q = new QuoteSpan(colorBlockquote); - else - q = new QuoteSpan(colorBlockquote, quoteStripe, quoteGap); - bodyBuilder.setSpan(q, - bodyBuilder.getSpanStart(quoteSpan), - bodyBuilder.getSpanEnd(quoteSpan), - bodyBuilder.getSpanFlags(quoteSpan)); - bodyBuilder.removeSpan(quoteSpan); + spannedBody = bodyBuilder; } - spannedBody = bodyBuilder; - Spanned spannedRef = null; if (!ref.isEmpty()) { Document dref = JsoupEx.parse(ref.outerHtml()); diff --git a/app/src/main/res/drawable/twotone_data_array_24.xml b/app/src/main/res/drawable/twotone_data_array_24.xml new file mode 100644 index 0000000000..3f9d0b56d1 --- /dev/null +++ b/app/src/main/res/drawable/twotone_data_array_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/menu/menu_compose.xml b/app/src/main/res/menu/menu_compose.xml index f13c735538..ffbc644855 100644 --- a/app/src/main/res/menu/menu_compose.xml +++ b/app/src/main/res/menu/menu_compose.xml @@ -79,6 +79,13 @@ android:icon="@drawable/outline_unfold_less_24" android:title="@string/title_compact" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2c2671e99..a366294e23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1739,6 +1739,7 @@ Show image options Style toolbar Media toolbar + Markdown Manage Android\'s contacts Manage local contacts Insert contact group