Experiment: markdown support

This commit is contained in:
M66B 2024-02-18 15:28:17 +01:00
parent 8937b3e2a6
commit 3350c11980
7 changed files with 132 additions and 25 deletions

View File

@ -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). * [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). * [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). * [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).

View File

@ -134,7 +134,11 @@ android {
'LICENSE-2.0.txt', 'LICENSE-2.0.txt',
'RELEASE.txt', 'RELEASE.txt',
'DebugProbesKt.bin', '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 vcard_version = "0.12.1"
def relinker_version = "1.4.5" def relinker_version = "1.4.5"
def markwon_version = "4.6.2" def markwon_version = "4.6.2"
def flexmark_version = "0.64.8"
def bouncycastle_version = "1.77" def bouncycastle_version = "1.77"
def colorpicker_version = "0.0.15" def colorpicker_version = "0.0.15"
def overscroll_version = "1.1.1" def overscroll_version = "1.1.1"
@ -765,6 +770,13 @@ dependencies {
// https://mvnrepository.com/artifact/io.noties.markwon/core // https://mvnrepository.com/artifact/io.noties.markwon/core
implementation "io.noties.markwon:core:$markwon_version" implementation "io.noties.markwon:core:$markwon_version"
implementation "io.noties.markwon:html:$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 // // https://github.com/QuadFlask/colorpicker
//implementation "com.github.QuadFlask:colorpicker:$colorpicker_version" //implementation "com.github.QuadFlask:colorpicker:$colorpicker_version"

View File

@ -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). * [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). * [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). * [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).

View File

@ -136,6 +136,12 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.bottomnavigation.LabelVisibilityMode; import com.google.android.material.bottomnavigation.LabelVisibilityMode;
import com.google.android.material.snackbar.Snackbar; 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.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.cert.jcajce.JcaCertStore; import org.bouncycastle.cert.jcajce.JcaCertStore;
@ -227,6 +233,9 @@ import javax.mail.util.ByteArrayDataSource;
import biweekly.ICalendar; import biweekly.ICalendar;
import biweekly.component.VEvent; import biweekly.component.VEvent;
import biweekly.property.Organizer; 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 { public class FragmentCompose extends FragmentBase {
private enum State {NONE, LOADING, LOADED} private enum State {NONE, LOADING, LOADED}
@ -280,6 +289,7 @@ public class FragmentCompose extends FragmentBase {
private ContentResolver resolver; private ContentResolver resolver;
private AdapterAttachment adapter; private AdapterAttachment adapter;
private MarkwonEditorTextWatcher markwonWatcher;
private boolean autoscroll_editor; private boolean autoscroll_editor;
private int compose_color; private int compose_color;
@ -291,6 +301,7 @@ public class FragmentCompose extends FragmentBase {
private boolean style = false; private boolean style = false;
private boolean media = true; private boolean media = true;
private boolean compact = false; private boolean compact = false;
private boolean markdown = false;
private int zoom = 0; private int zoom = 0;
private boolean nav_color; private boolean nav_color;
private boolean lt_enabled; private boolean lt_enabled;
@ -1119,6 +1130,19 @@ public class FragmentCompose extends FragmentBase {
invalidateOptionsMenu(); invalidateOptionsMenu();
Helper.setViewsEnabled(view, false); 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()); final DB db = DB.getInstance(getContext());
SimpleCursorAdapter cadapter = new SimpleCursorAdapter( 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_style).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_media).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_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_contact_group).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_manage_android_contacts).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); 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_chips = prefs.getBoolean("send_chips", true);
boolean send_dialog = prefs.getBoolean("send_dialog", true); boolean send_dialog = prefs.getBoolean("send_dialog", true);
boolean image_dialog = prefs.getBoolean("image_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_save_drafts).setChecked(save_drafts);
menu.findItem(R.id.menu_send_chips).setChecked(send_chips); 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_style).setChecked(style);
menu.findItem(R.id.menu_media).setChecked(media); menu.findItem(R.id.menu_media).setChecked(media);
menu.findItem(R.id.menu_compact).setChecked(compact); 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); View image = media_bar.findViewById(R.id.menu_image);
if (image != null) if (image != null)
@ -2083,6 +2111,9 @@ public class FragmentCompose extends FragmentBase {
} else if (itemId == R.id.menu_compact) { } else if (itemId == R.id.menu_compact) {
onMenuCompact(); onMenuCompact();
return true; return true;
} else if (itemId == R.id.menu_markdown) {
onMenuMarkdown();
return true;
} else if (itemId == R.id.menu_contact_group) { } else if (itemId == R.id.menu_contact_group) {
onMenuContactGroup(); onMenuContactGroup();
return true; return true;
@ -2315,6 +2346,11 @@ public class FragmentCompose extends FragmentBase {
setCompact(compact); setCompact(compact);
} }
private void onMenuMarkdown() {
markdown = !markdown;
onAction(R.id.menu_save, "Markdown");
}
private void setCompact(boolean compact) { private void setCompact(boolean compact) {
bottom_navigation.setLabelVisibilityMode(compact bottom_navigation.setLabelVisibilityMode(compact
? LabelVisibilityMode.LABEL_VISIBILITY_UNLABELED ? LabelVisibilityMode.LABEL_VISIBILITY_UNLABELED
@ -4990,6 +5026,7 @@ public class FragmentCompose extends FragmentBase {
args.putString("subject", etSubject.getText().toString().trim()); args.putString("subject", etSubject.getText().toString().trim());
args.putCharSequence("loaded", (Spanned) etBody.getTag()); args.putCharSequence("loaded", (Spanned) etBody.getTag());
args.putCharSequence("spanned", etBody.getText()); args.putCharSequence("spanned", etBody.getText());
args.putBoolean("markdown", markdown);
args.putBoolean("signature", cbSignature.isChecked()); args.putBoolean("signature", cbSignature.isChecked());
args.putBoolean("empty", isEmpty()); args.putBoolean("empty", isEmpty());
args.putBoolean("notext", notext); args.putBoolean("notext", notext);
@ -6171,6 +6208,9 @@ public class FragmentCompose extends FragmentBase {
Elements ref = doc.select("div[fairemail=reference]"); Elements ref = doc.select("div[fairemail=reference]");
ref.remove(); ref.remove();
boolean markdown = Boolean.parseBoolean(doc.body().attr("markdown"));
args.putBoolean("markdown", markdown);
File refFile = data.draft.getRefFile(context); File refFile = data.draft.getRefFile(context);
if (refFile.exists()) { if (refFile.exists()) {
ref.html(Helper.readText(refFile)); ref.html(Helper.readText(refFile));
@ -6243,6 +6283,7 @@ public class FragmentCompose extends FragmentBase {
working = data.draft.id; working = data.draft.id;
dsn = (data.draft.dsn != null && !EntityMessage.DSN_NONE.equals(data.draft.dsn)); dsn = (data.draft.dsn != null && !EntityMessage.DSN_NONE.equals(data.draft.dsn));
encrypt = data.draft.ui_encrypt; encrypt = data.draft.ui_encrypt;
markdown = args.getBoolean("markdown");
invalidateOptionsMenu(); invalidateOptionsMenu();
subject = data.draft.subject; subject = data.draft.subject;
@ -6665,6 +6706,7 @@ public class FragmentCompose extends FragmentBase {
String subject = args.getString("subject"); String subject = args.getString("subject");
Spanned loaded = (Spanned) args.getCharSequence("loaded"); Spanned loaded = (Spanned) args.getCharSequence("loaded");
Spanned spanned = (Spanned) args.getCharSequence("spanned"); Spanned spanned = (Spanned) args.getCharSequence("spanned");
boolean markdown = args.getBoolean("markdown");
boolean signature = args.getBoolean("signature"); boolean signature = args.getBoolean("signature");
boolean empty = args.getBoolean("empty"); boolean empty = args.getBoolean("empty");
boolean notext = args.getBoolean("notext"); boolean notext = args.getBoolean("notext");
@ -6673,7 +6715,22 @@ public class FragmentCompose extends FragmentBase {
boolean silent = extras.getBoolean("silent"); boolean silent = extras.getBoolean("silent");
boolean dirty = false; 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; EntityMessage draft;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
@ -7581,12 +7638,15 @@ public class FragmentCompose extends FragmentBase {
Bundle args = new Bundle(); Bundle args = new Bundle();
args.putLong("id", draft.id); args.putLong("id", draft.id);
args.putBoolean("show_images", show_images); args.putBoolean("show_images", show_images);
args.putBoolean("markdown", markdown);
new SimpleTask<Spanned[]>() { new SimpleTask<Spanned[]>() {
@Override @Override
protected void onPreExecute(Bundle args) { protected void onPreExecute(Bundle args) {
// Needed to get width for images // Needed to get width for images
grpBody.setVisibility(View.VISIBLE); grpBody.setVisibility(View.VISIBLE);
if (markwonWatcher != null)
etBody.removeTextChangedListener(markwonWatcher);
} }
@Override @Override
@ -7602,12 +7662,18 @@ public class FragmentCompose extends FragmentBase {
Helper.setViewsEnabled(view, true); Helper.setViewsEnabled(view, true);
invalidateOptionsMenu(); invalidateOptionsMenu();
if (markdown && markwonWatcher != null) {
etBody.addTextChangedListener(markwonWatcher);
markwonWatcher.afterTextChanged(etBody.getText());
}
} }
@Override @Override
protected Spanned[] onExecute(final Context context, Bundle args) throws Throwable { protected Spanned[] onExecute(final Context context, Bundle args) throws Throwable {
final long id = args.getLong("id"); final long id = args.getLong("id");
final boolean show_images = args.getBoolean("show_images", false); 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); int colorPrimary = Helper.resolveColor(context, androidx.appcompat.R.attr.colorPrimary);
final int colorBlockquote = Helper.resolveColor(context, R.attr.colorBlockquote, 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]"); Elements ref = doc.select("div[fairemail=reference]");
ref.remove(); 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() { spannedBody = HtmlHelper.fromDocument(context, doc, new HtmlHelper.ImageGetterEx() {
@Override @Override
public Drawable getDrawable(Element element) { public Drawable getDrawable(Element element) {
return ImageHelper.decodeImage(context, return ImageHelper.decodeImage(context,
id, element, true, zoom, 1.0f, etBody); 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); spannedBody = bodyBuilder;
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;
Spanned spannedRef = null; Spanned spannedRef = null;
if (!ref.isEmpty()) { if (!ref.isEmpty()) {
Document dref = JsoupEx.parse(ref.outerHtml()); Document dref = JsoupEx.parse(ref.outerHtml());

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15,4l0,2l3,0l0,12l-3,0l0,2l5,0l0,-16z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M4,20l5,0l0,-2l-3,0l0,-12l3,0l0,-2l-5,0z"/>
</vector>

View File

@ -79,6 +79,13 @@
android:icon="@drawable/outline_unfold_less_24" android:icon="@drawable/outline_unfold_less_24"
android:title="@string/title_compact" android:title="@string/title_compact"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/menu_markdown"
android:checkable="true"
android:icon="@drawable/twotone_data_array_24"
android:title="@string/title_markdown"
app:showAsAction="never" />
</group> </group>
<group android:id="@+id/group_operations"> <group android:id="@+id/group_operations">

View File

@ -1739,6 +1739,7 @@
<string name="title_image_dialog">Show image options</string> <string name="title_image_dialog">Show image options</string>
<string name="title_style_toolbar">Style toolbar</string> <string name="title_style_toolbar">Style toolbar</string>
<string name="title_media_toolbar">Media toolbar</string> <string name="title_media_toolbar">Media toolbar</string>
<string name="title_markdown" translatable="false">Markdown</string>
<string name="title_manage_android_contacts">Manage Android\'s contacts</string> <string name="title_manage_android_contacts">Manage Android\'s contacts</string>
<string name="title_manage_local_contacts">Manage local contacts</string> <string name="title_manage_local_contacts">Manage local contacts</string>
<string name="title_insert_contact_group">Insert contact group</string> <string name="title_insert_contact_group">Insert contact group</string>