diff --git a/app/src/main/java/eu/faircode/email/AI.java b/app/src/main/java/eu/faircode/email/AI.java index fe5de55cc8..5f187dc7c8 100644 --- a/app/src/main/java/eu/faircode/email/AI.java +++ b/app/src/main/java/eu/faircode/email/AI.java @@ -72,7 +72,7 @@ public class AI { DB db = DB.getInstance(context); EntityAnswer t = db.answer().getAnswer(template); if (t != null) { - String html = t.getHtml(context, null); + String html = t.getData(context, null).getHtml(); prompt = JsoupEx.parse(html).body().text(); } } diff --git a/app/src/main/java/eu/faircode/email/ActivityAnswer.java b/app/src/main/java/eu/faircode/email/ActivityAnswer.java index 3ed1e59332..7963a4bb0b 100644 --- a/app/src/main/java/eu/faircode/email/ActivityAnswer.java +++ b/app/src/main/java/eu/faircode/email/ActivityAnswer.java @@ -81,7 +81,7 @@ public class ActivityAnswer extends ActivityBase { } }); - String html = answer.getHtml(context, null); + String html = answer.getData(context, null).getHtml(); String text = HtmlHelper.getText(context, html); ClipboardManager cbm = Helper.getSystemService(ActivityAnswer.this, ClipboardManager.class); diff --git a/app/src/main/java/eu/faircode/email/EditTextCompose.java b/app/src/main/java/eu/faircode/email/EditTextCompose.java index 53654750d4..c36b52f97a 100644 --- a/app/src/main/java/eu/faircode/email/EditTextCompose.java +++ b/app/src/main/java/eu/faircode/email/EditTextCompose.java @@ -396,7 +396,7 @@ public class EditTextCompose extends FixedEditText { for (EntityAnswer snippet : snippets) if (snippet.id.equals(id)) { - String html = snippet.getHtml(context, to); + String html = snippet.getData(context, to).getHtml(); Helper.getUIExecutor().submit(new Runnable() { @Override diff --git a/app/src/main/java/eu/faircode/email/EntityAnswer.java b/app/src/main/java/eu/faircode/email/EntityAnswer.java index 02be4c89be..852c0a6a41 100644 --- a/app/src/main/java/eu/faircode/email/EntityAnswer.java +++ b/app/src/main/java/eu/faircode/email/EntityAnswer.java @@ -25,6 +25,7 @@ import android.content.SharedPreferences; import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.text.Html; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -45,6 +46,10 @@ import androidx.room.PrimaryKey; import org.json.JSONException; import org.json.JSONObject; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; import java.io.Serializable; import java.text.Collator; @@ -63,6 +68,7 @@ import java.util.Objects; import java.util.UUID; import javax.mail.Address; +import javax.mail.Part; import javax.mail.internet.InternetAddress; // https://developer.android.com/training/data-storage/room/defining-data @@ -107,9 +113,33 @@ public class EntityAnswer implements Serializable { public Integer applied = 0; public Long last_applied; + static final String ATTACHMENT_PREFIX = "[attachment:"; + static final String ATTACHMENT_SUFFIX = "]"; private static final String PREF_PLACEHOLDER = "answer.value."; - String getHtml(Context context, Address[] address) { + @NonNull + Data getData(Context context, Address[] address) { + Data result = new Data(); + + Document doc = JsoupEx.parse(text); + for (Element span : doc.select("span")) { + Node node = span.firstChild(); + if (node instanceof TextNode) { + String text = ((TextNode) node).getWholeText().trim(); + if (text.startsWith(ATTACHMENT_PREFIX) && text.endsWith(ATTACHMENT_SUFFIX)) { + String name = text.substring(ATTACHMENT_PREFIX.length(), text.length() - 1); + result.attachments.add(Uri.parse(name)); + + Element next = span.nextElementSibling(); + span.remove(); + if (next != null && "br".equals(next.nodeName())) + next.remove(); + } + } + } + + result.html = doc.html(); + String fullName = null; String email = null; if (address != null && address.length > 0) { @@ -179,19 +209,19 @@ public class EntityAnswer implements Serializable { first = Helper.trim(first, "."); last = Helper.trim(last, "."); - text = text.replace("$name$", fullName == null ? "" : Html.escapeHtml(fullName)); - text = text.replace("$firstname$", first == null ? "" : Html.escapeHtml(first)); - text = text.replace("$lastname$", last == null ? "" : Html.escapeHtml(last)); - text = text.replace("$email$", email == null ? "" : Html.escapeHtml(email)); + result.html = result.html.replace("$name$", fullName == null ? "" : Html.escapeHtml(fullName)); + result.html = result.html.replace("$firstname$", first == null ? "" : Html.escapeHtml(first)); + result.html = result.html.replace("$lastname$", last == null ? "" : Html.escapeHtml(last)); + result.html = result.html.replace("$email$", email == null ? "" : Html.escapeHtml(email)); - int s = text.indexOf("$date"); + int s = result.html.indexOf("$date"); while (s >= 0) { - int e = text.indexOf('$', s + 5); + int e = result.html.indexOf('$', s + 5); if (e < 0) break; Calendar c = null; - String v = text.substring(s + 5, e); + String v = result.html.substring(s + 5, e); if (v.startsWith("-") || v.startsWith("+")) { Integer days = Helper.parseInt(v.substring(1)); if (days != null && days >= 0 && days < 10 * 365) { @@ -202,15 +232,15 @@ public class EntityAnswer implements Serializable { c = Calendar.getInstance(); if (c == null) - s = text.indexOf("$date", e + 1); + s = result.html.indexOf("$date", e + 1); else { v = Html.escapeHtml(SimpleDateFormat.getDateInstance(SimpleDateFormat.LONG).format(c.getTime())); - text = text.substring(0, s) + v + text.substring(e + 1); - s = text.indexOf("$date", s + v.length()); + result.html = result.html.substring(0, s) + v + result.html.substring(e + 1); + s = result.html.indexOf("$date", s + v.length()); } } - text = text.replace("$weekday$", new SimpleDateFormat("EEEE").format(new Date())); + result.html = result.html.replace("$weekday$", new SimpleDateFormat("EEEE").format(new Date())); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); for (String key : prefs.getAll().keySet()) @@ -222,13 +252,13 @@ public class EntityAnswer implements Serializable { for (int i = 0; i < lines.length; i++) lines[i] = Html.escapeHtml(lines[i]); - text = text.replace("$" + name + "$", TextUtils.join("
", lines)); + result.html = result.html.replace("$" + name + "$", TextUtils.join("
", lines)); } if (BuildConfig.DEBUG) - text = text.replace("$version$", BuildConfig.VERSION_NAME); + result.html = result.html.replace("$version$", BuildConfig.VERSION_NAME); - return text; + return result; } static void setCustomPlaceholder(Context context, String name, String value) { @@ -536,4 +566,37 @@ public class EntityAnswer implements Serializable { public String toString() { return name + (favorite ? " ★" : ""); } + + public class Data { + private String html; + private List attachments = new ArrayList<>(); + + public String getHtml() { + return this.html; + } + + public void insertAttachments(Context context, long id) { + DB db = DB.getInstance(context); + for (Uri file : attachments) + try { + EntityAttachment attachment = new EntityAttachment(); + Helper.UriInfo info = Helper.getInfo(file, context); + + attachment.message = id; + attachment.sequence = db.attachment().getAttachmentSequence(id) + 1; + attachment.name = info.name; + attachment.type = info.type; + attachment.disposition = Part.ATTACHMENT; + attachment.size = info.size; + attachment.progress = 0; + + attachment.id = db.attachment().insertAttachment(attachment); + + long size = Helper.copy(context, file, attachment.getFile(context)); + db.attachment().setDownloaded(attachment.id, size); + } catch (Throwable ex) { + Log.e(ex); + } + } + } } diff --git a/app/src/main/java/eu/faircode/email/EntityRule.java b/app/src/main/java/eu/faircode/email/EntityRule.java index 86a53d784e..1a5e7b7cc2 100644 --- a/app/src/main/java/eu/faircode/email/EntityRule.java +++ b/app/src/main/java/eu/faircode/email/EntityRule.java @@ -1159,10 +1159,12 @@ public class EntityRule { reply.id = db.message().insertMessage(reply); String body; + EntityAnswer.Data answerData = null; if (resend) body = Helper.readText(message.getFile(context)); else { - body = answer.getHtml(context, message.from); + answerData = answer.getData(context, message.from); + body = answerData.getHtml(); if (original_text) { Document msg = JsoupEx.parse(body); @@ -1219,6 +1221,9 @@ public class EntityRule { db.attachment().setDownloaded(attachment.id, target.length()); } + if (answerData != null) + answerData.insertAttachments(context, reply.id); + EntityOperation.queue(context, reply, EntityOperation.SEND); // Batch send operations, wait until after commit diff --git a/app/src/main/java/eu/faircode/email/FragmentAnswer.java b/app/src/main/java/eu/faircode/email/FragmentAnswer.java index e531bb73d0..b77e121720 100644 --- a/app/src/main/java/eu/faircode/email/FragmentAnswer.java +++ b/app/src/main/java/eu/faircode/email/FragmentAnswer.java @@ -90,8 +90,9 @@ public class FragmentAnswer extends FragmentBase { private static final int REQUEST_COLOR = 1; private static final int REQUEST_IMAGE = 2; - private static final int REQUEST_LINK = 3; - private final static int REQUEST_DELETE = 4; + private static final int REQUEST_FILE = 3; + private static final int REQUEST_LINK = 4; + private final static int REQUEST_DELETE = 5; @Override public void onCreate(Bundle savedInstanceState) { @@ -185,6 +186,9 @@ public class FragmentAnswer extends FragmentBase { if (itemId == R.id.action_insert_image) { onInsertImage(); return true; + } else if (itemId == R.id.action_attach_file) { + onAttachFile(); + return true; } else if (itemId == R.id.action_insert_link) { onInsertLink(); return true; @@ -413,6 +417,16 @@ public class FragmentAnswer extends FragmentBase { startActivityForResult(intent, REQUEST_IMAGE); } + private void onAttachFile() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setType("*/*"); + Helper.openAdvanced(getContext(), intent); + startActivityForResult(intent, REQUEST_FILE); + } + private void onInsertLink() { FragmentDialogInsertLink fragment = new FragmentDialogInsertLink(); fragment.setArguments(FragmentDialogInsertLink.getArguments(etText)); @@ -567,6 +581,10 @@ public class FragmentAnswer extends FragmentBase { if (resultCode == RESULT_OK && data != null) onImageSelected(data.getData()); break; + case REQUEST_FILE: + if (resultCode == RESULT_OK && data != null) + onFileSelected(data.getData()); + break; case REQUEST_LINK: if (resultCode == RESULT_OK && data != null) onLinkSelected(data.getBundleExtra("args")); @@ -605,6 +623,26 @@ public class FragmentAnswer extends FragmentBase { } } + private void onFileSelected(Uri uri) { + try { + NoStreamException.check(uri, getContext()); + + getContext().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (!Helper.isPersisted(getContext(), uri, true, false)) + throw new IllegalStateException("No permission granted to access selected file " + uri); + + Editable edit = etText.getText(); + if (edit.length() > 0 && edit.charAt(edit.length() - 1) != '\n') + edit.append("\n"); + edit.append(EntityAnswer.ATTACHMENT_PREFIX + uri + EntityAnswer.ATTACHMENT_SUFFIX + "\n"); + etText.setSelection(edit.length()); + } catch (NoStreamException ex) { + ex.report(getActivity()); + } catch (Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + } + private void onLinkSelected(Bundle args) { String link = args.getString("link"); boolean image = args.getBoolean("image"); diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 777a4c17fb..aea6687b9d 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -121,7 +121,6 @@ import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuCompat; import androidx.core.view.WindowInsetsCompat; import androidx.cursoradapter.widget.SimpleCursorAdapter; -import androidx.documentfile.provider.DocumentFile; import androidx.exifinterface.media.ExifInterface; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -221,7 +220,6 @@ import javax.mail.internet.MimeBodyPart; import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMultipart; import javax.mail.internet.MimeUtility; -import javax.mail.internet.ParseException; import javax.mail.util.ByteArrayDataSource; import biweekly.ICalendar; @@ -2498,17 +2496,19 @@ public class FragmentCompose extends FragmentBase { long id = intent.getLongExtra("id", -1); Bundle args = new Bundle(); - args.putLong("id", id); + args.putLong("id", working); + args.putLong("aid", id); args.putString("to", etTo.getText().toString()); new SimpleTask() { @Override - protected EntityAnswer onExecute(Context context, Bundle args) throws Throwable { + protected EntityAnswer onExecute(Context context, Bundle args) { long id = args.getLong("id"); + long aid = args.getLong("aid"); String to = args.getString("to"); DB db = DB.getInstance(context); - EntityAnswer answer = db.answer().getAnswer(id); + EntityAnswer answer = db.answer().getAnswer(aid); if (answer != null) { InternetAddress[] tos = null; try { @@ -2516,7 +2516,8 @@ public class FragmentCompose extends FragmentBase { } catch (AddressException ignored) { } - String html = answer.getHtml(context, tos); + EntityAnswer.Data answerData = answer.getData(context, tos); + String html = answerData.getHtml(); Document d = HtmlHelper.sanitizeCompose(context, html, true); Spanned spanned = HtmlHelper.fromDocument(context, d, new HtmlHelper.ImageGetterEx() { @@ -2531,6 +2532,8 @@ public class FragmentCompose extends FragmentBase { }, null); args.putCharSequence("spanned", spanned); + answerData.insertAttachments(context, id); + db.answer().applyAnswer(answer.id, new Date().getTime()); } @@ -3863,7 +3866,7 @@ public class FragmentCompose extends FragmentBase { ArrayList images = new ArrayList<>(); for (Uri uri : uris) try { - UriInfo info = getInfo(uri, context); + Helper.UriInfo info = Helper.getInfo(uri, context); if (info.isImage()) images.add(uri); else @@ -5151,7 +5154,7 @@ public class FragmentCompose extends FragmentBase { NoStreamException.check(uri, context); EntityAttachment attachment = new EntityAttachment(); - UriInfo info = getInfo(uri, context); + Helper.UriInfo info = Helper.getInfo(uri, context); EntityLog.log(context, "Add attachment" + " uri=" + uri + " type=" + type + " image=" + image + " resize=" + resize + " privacy=" + privacy + @@ -5669,6 +5672,7 @@ public class FragmentCompose extends FragmentBase { Document document = Document.createShell(""); + EntityAnswer.Data answerData = null; if (ref == null) { data.draft.thread = data.draft.msgid; @@ -5710,7 +5714,8 @@ public class FragmentCompose extends FragmentBase { if (answer > 0) data.draft.subject = a.name; if (TextUtils.isEmpty(external_body)) { - Document d = JsoupEx.parse(a.getHtml(context, null)); + answerData = a.getData(context, null); + Document d = JsoupEx.parse(answerData.getHtml()); document.body().append(d.body().html()); } } @@ -5930,7 +5935,7 @@ public class FragmentCompose extends FragmentBase { else { db.answer().applyAnswer(receipt.id, new Date().getTime()); texts = new String[0]; - Document d = JsoupEx.parse(receipt.getHtml(context, null)); + Document d = JsoupEx.parse(receipt.getData(context, null).getHtml()); document.body().append(d.body().html()); } } @@ -5988,7 +5993,8 @@ public class FragmentCompose extends FragmentBase { db.answer().applyAnswer(a.id, new Date().getTime()); if (a.label != null && ref != null) EntityOperation.queue(context, ref, EntityOperation.LABEL, a.label, true); - Document d = JsoupEx.parse(a.getHtml(context, data.draft.to)); + answerData = a.getData(context, data.draft.to); + Document d = JsoupEx.parse(answerData.getHtml()); document.body().append(d.body().html()); } @@ -6196,7 +6202,7 @@ public class FragmentCompose extends FragmentBase { ArrayList images = new ArrayList<>(); for (Uri uri : uris) try { - UriInfo info = getInfo(uri, context); + Helper.UriInfo info = Helper.getInfo(uri, context); if (info.isImage()) images.add(uri); else @@ -6255,6 +6261,9 @@ public class FragmentCompose extends FragmentBase { } } + if (answerData != null) + answerData.insertAttachments(context, data.draft.id); + if (save_drafts && (data.draft.ui_encrypt == null || EntityMessage.ENCRYPT_NONE.equals(data.draft.ui_encrypt)) && @@ -8313,67 +8322,6 @@ public class FragmentCompose extends FragmentBase { } }; - @NonNull - private static UriInfo getInfo(Uri uri, Context context) { - UriInfo result = new UriInfo(); - - // https://stackoverflow.com/questions/76094229/android-13-photo-video-picker-file-name-from-the-uri-is-garbage - DocumentFile dfile = null; - try { - dfile = DocumentFile.fromSingleUri(context, uri); - if (dfile != null) { - result.name = dfile.getName(); - result.type = dfile.getType(); - result.size = dfile.length(); - EntityLog.log(context, "UriInfo dfile " + result + " uri=" + uri); - } - } catch (Throwable ex) { - Log.e(ex); - } - - // Check name - if (TextUtils.isEmpty(result.name)) - result.name = uri.getLastPathSegment(); - - // Check type - if (!TextUtils.isEmpty(result.type)) - try { - new ContentType(result.type); - } catch (ParseException ex) { - Log.w(new Throwable(result.type, ex)); - result.type = null; - } - - if (TextUtils.isEmpty(result.type) || - "*/*".equals(result.type) || - "application/*".equals(result.type) || - "application/octet-stream".equals(result.type)) - result.type = Helper.guessMimeType(result.name); - - if (result.size != null && result.size <= 0) - result.size = null; - - EntityLog.log(context, "UriInfo result " + result + " uri=" + uri); - - return result; - } - - private static class UriInfo { - String name; - String type; - Long size; - - boolean isImage() { - return ImageHelper.isImage(type); - } - - @NonNull - @Override - public String toString() { - return "name=" + name + " type=" + type + " size=" + size; - } - } - private static class DraftData { private EntityMessage draft; private List identities; diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index 7d28690eb6..926da8d41b 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -120,6 +120,7 @@ import androidx.core.graphics.ColorUtils; import androidx.core.view.SoftwareKeyboardControllerCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; @@ -183,6 +184,9 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Pattern; +import javax.mail.internet.ContentType; +import javax.mail.internet.ParseException; + public class Helper { private static Integer targetSdk = null; private static Boolean hasWebView = null; @@ -2793,6 +2797,67 @@ public class Helper { return extension; } + @NonNull + static UriInfo getInfo(Uri uri, Context context) { + UriInfo result = new UriInfo(); + + // https://stackoverflow.com/questions/76094229/android-13-photo-video-picker-file-name-from-the-uri-is-garbage + DocumentFile dfile = null; + try { + dfile = DocumentFile.fromSingleUri(context, uri); + if (dfile != null) { + result.name = dfile.getName(); + result.type = dfile.getType(); + result.size = dfile.length(); + EntityLog.log(context, "UriInfo dfile " + result + " uri=" + uri); + } + } catch (Throwable ex) { + Log.e(ex); + } + + // Check name + if (TextUtils.isEmpty(result.name)) + result.name = uri.getLastPathSegment(); + + // Check type + if (!TextUtils.isEmpty(result.type)) + try { + new ContentType(result.type); + } catch (ParseException ex) { + Log.w(new Throwable(result.type, ex)); + result.type = null; + } + + if (TextUtils.isEmpty(result.type) || + "*/*".equals(result.type) || + "application/*".equals(result.type) || + "application/octet-stream".equals(result.type)) + result.type = Helper.guessMimeType(result.name); + + if (result.size != null && result.size <= 0) + result.size = null; + + EntityLog.log(context, "UriInfo result " + result + " uri=" + uri); + + return result; + } + + static class UriInfo { + String name; + String type; + Long size; + + boolean isImage() { + return ImageHelper.isImage(type); + } + + @NonNull + @Override + public String toString() { + return "name=" + name + " type=" + type + " size=" + size; + } + } + static void writeText(File file, String content) throws IOException { try (FileOutputStream out = new FileOutputStream(file)) { if (content != null) diff --git a/app/src/main/java/eu/faircode/email/ServiceExternal.java b/app/src/main/java/eu/faircode/email/ServiceExternal.java index cdfc4a0059..97a03f4232 100644 --- a/app/src/main/java/eu/faircode/email/ServiceExternal.java +++ b/app/src/main/java/eu/faircode/email/ServiceExternal.java @@ -326,7 +326,8 @@ public class ServiceExternal extends ServiceBase { new InternetAddress(identity.get(0).email, identity.get(0).name, StandardCharsets.UTF_8.name())}; if (subject == null) // Allow empty string subject = answers.get(0).name; - String body = answers.get(0).getHtml(context, to); + EntityAnswer.Data answerData = answers.get(0).getData(context, to); + String body = answerData.getHtml(); EntityMessage msg = new EntityMessage(); msg.account = identity.get(0).account; @@ -356,6 +357,8 @@ public class ServiceExternal extends ServiceBase { msg.preview, null); + answerData.insertAttachments(context, msg.id); + EntityOperation.queue(context, msg, EntityOperation.SEND); ServiceSend.start(context); } diff --git a/app/src/main/res/menu/action_answer.xml b/app/src/main/res/menu/action_answer.xml index b331153269..1b09aa3f3e 100644 --- a/app/src/main/res/menu/action_answer.xml +++ b/app/src/main/res/menu/action_answer.xml @@ -5,6 +5,11 @@ android:icon="@drawable/twotone_image_24" android:title="@string/title_edit_signature_image" /> + +