From 1c0a9f287edbb2e675dbf6bfa2bd48aecfc58c77 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 5 May 2023 23:49:54 +0200 Subject: [PATCH] quote as extra line, quote improvements for slidge, only set "note to self" on *copy*, reduce RAM pressure by the cache a bit (Cheogram) --- build.gradle | 4 +- .../java/de/monocles/chat/BobTransfer.java | 199 ++++++++++++++++++ .../de/monocles/chat/GetThumbnailForCid.java | 9 + .../siacs/conversations/entities/Contact.java | 4 + .../siacs/conversations/entities/Message.java | 80 ++++++- .../persistance/DatabaseBackend.java | 39 ++++ .../persistance/FileBackend.java | 27 ++- .../services/XmppConnectionService.java | 20 +- .../ui/ConversationFragment.java | 42 +++- .../ui/StartConversationActivity.java | 4 +- .../ui/adapter/MessageAdapter.java | 2 +- .../conversations/utils/CryptoHelper.java | 55 +++++ src/main/res/layout/fragment_conversation.xml | 44 +++- src/main/res/values/strings.xml | 1 + 14 files changed, 506 insertions(+), 24 deletions(-) create mode 100644 src/main/java/de/monocles/chat/BobTransfer.java create mode 100644 src/main/java/de/monocles/chat/GetThumbnailForCid.java diff --git a/build.gradle b/build.gradle index e03f121b6..18d0edbb4 100644 --- a/build.gradle +++ b/build.gradle @@ -96,8 +96,10 @@ dependencies { implementation 'com.github.AppIntro:AppIntro:6.2.0' implementation 'androidx.browser:browser:1.4.0' implementation 'com.otaliastudios:transcoder:0.9.1' // 0.10.4 seems to be buggy - implementation 'me.saket:better-link-movement-method:2.2.0' + implementation 'com.github.singpolyma:Better-Link-Movement-Method:4df081e1e4' implementation project(':libs:AXML') + implementation 'com.github.ipld:java-cid:v1.3.1' + } ext { diff --git a/src/main/java/de/monocles/chat/BobTransfer.java b/src/main/java/de/monocles/chat/BobTransfer.java new file mode 100644 index 000000000..4d53a88bf --- /dev/null +++ b/src/main/java/de/monocles/chat/BobTransfer.java @@ -0,0 +1,199 @@ +package de.monocles.chat; + +import android.net.Uri; +import android.util.Base64; +import android.util.Log; + +import java.util.Map; +import java.util.HashMap; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; + +import io.ipfs.cid.Cid; +import io.ipfs.multihash.Multihash; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Transferable; +import eu.siacs.conversations.http.AesGcmURL; +import eu.siacs.conversations.services.AbstractConnectionManager; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.MimeUtils; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class BobTransfer implements Transferable { + protected int status = Transferable.STATUS_OFFER; + protected URI uri; + protected Account account; + protected Jid to; + protected XmppConnectionService xmppConnectionService; + protected static Map attempts = new HashMap<>(); + + public static Cid cid(Uri uri) { + if (uri == null || uri.getScheme() == null || !uri.getScheme().equals("cid")) return null; + return cid(uri.getSchemeSpecificPart()); + } + + public static Cid cid(URI uri) { + if (uri == null || uri.getScheme() == null || !uri.getScheme().equals("cid")) return null; + return cid(uri.getSchemeSpecificPart()); + } + + public static Cid cid(String bobCid) { + if (!bobCid.contains("@") || !bobCid.contains("+")) return null; + String[] cidParts = bobCid.split("@")[0].split("\\+"); + try { + return CryptoHelper.cid(CryptoHelper.hexToBytes(cidParts[1]), cidParts[0]); + } catch (final NoSuchAlgorithmException e) { + return null; + } + } + + public static URI uri(Cid cid) throws NoSuchAlgorithmException, URISyntaxException { + return new URI("cid", multihashAlgo(cid.getType()) + "+" + CryptoHelper.bytesToHex(cid.getHash()) + "@bob.xmpp.org", null); + } + + private static String multihashAlgo(Multihash.Type type) throws NoSuchAlgorithmException { + final String algo = CryptoHelper.multihashAlgo(type); + if (algo.equals("sha-1")) return "sha1"; + return algo; + } + + public BobTransfer(URI uri, Account account, Jid to, XmppConnectionService xmppConnectionService) { + this.xmppConnectionService = xmppConnectionService; + this.uri = uri; + this.to = to; + this.account = account; + } + + @Override + public boolean start() { + if (status == Transferable.STATUS_DOWNLOADING) return true; + File f = xmppConnectionService.getFileForCid(cid(uri)); + + if (f != null && f.canRead()) { + finish(f); + return true; + } + + if (xmppConnectionService.hasInternetConnection() && attempts.getOrDefault(uri, 0L) + 10000L < System.currentTimeMillis()) { + attempts.put(uri, System.currentTimeMillis()); + changeStatus(Transferable.STATUS_DOWNLOADING); + + IqPacket request = new IqPacket(IqPacket.TYPE.GET); + request.setTo(to); + final Element dataq = request.addChild("data", "urn:xmpp:bob"); + dataq.setAttribute("cid", uri.getSchemeSpecificPart()); + xmppConnectionService.sendIqPacket(account, request, (acct, packet) -> { + final Element data = packet.findChild("data", "urn:xmpp:bob"); + if (packet.getType() == IqPacket.TYPE.ERROR || data == null) { + Log.d(Config.LOGTAG, "BobTransfer failed: " + packet); + finish(null); + } else { + final String contentType = data.getAttribute("type"); + String fileExtension = "dat"; + if (contentType != null) { + fileExtension = MimeUtils.guessExtensionFromMimeType(contentType); + } + + try { + final byte[] bytes = Base64.decode(data.getContent(), Base64.DEFAULT); + + File file = xmppConnectionService.getFileBackend().getStorageLocation(new ByteArrayInputStream(bytes), fileExtension); + file.getParentFile().mkdirs(); + if (!file.exists() && !file.createNewFile()) { + throw new IOException(file.getAbsolutePath()); + } + + final OutputStream outputStream = AbstractConnectionManager.createOutputStream(new DownloadableFile(file.getAbsolutePath()), false, false); + + if (outputStream != null && bytes != null) { + outputStream.write(bytes); + outputStream.flush(); + outputStream.close(); + finish(file); + } else { + Log.w(Config.LOGTAG, "Could not write BobTransfer, null outputStream"); + finish(null); + } + } catch (final IOException | XmppConnectionService.BlockedMediaException e) { + Log.w(Config.LOGTAG, "Could not write BobTransfer: " + e); + finish(null); + } + } + }); + return true; + } else { + return false; + } + } + + @Override + public int getStatus() { + return status; + } + + @Override + public int getProgress() { + return 0; + } + + @Override + public Long getFileSize() { + return null; + } + + @Override + public void cancel() { + // No real way to cancel an iq in process... + changeStatus(Transferable.STATUS_CANCELLED); + } + + protected void changeStatus(int newStatus) { + status = newStatus; + xmppConnectionService.updateConversationUi(); + } + + protected void finish(File f) { + if (f != null) xmppConnectionService.updateConversationUi(); + } + + public static class ForMessage extends BobTransfer { + protected Message message; + + public ForMessage(Message message, XmppConnectionService xmppConnectionService) throws URISyntaxException { + super(new URI(message.getFileParams().url), message.getConversation().getAccount(), message.getCounterpart(), xmppConnectionService); + this.message = message; + } + + @Override + public void cancel() { + super.cancel(); + message.setTransferable(null); + } + + @Override + protected void finish(File f) { + if (f != null) { + message.setRelativeFilePath(f.getAbsolutePath()); + final boolean privateMessage = message.isPrivateMessage(); + message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE); + xmppConnectionService.getFileBackend().updateFileParams(message, uri.toString(), false); + xmppConnectionService.updateMessage(message); + } + message.setTransferable(null); + super.finish(f); + } + } +} diff --git a/src/main/java/de/monocles/chat/GetThumbnailForCid.java b/src/main/java/de/monocles/chat/GetThumbnailForCid.java new file mode 100644 index 000000000..e90f2e186 --- /dev/null +++ b/src/main/java/de/monocles/chat/GetThumbnailForCid.java @@ -0,0 +1,9 @@ +package de.monocles.chat; + +import android.graphics.drawable.Drawable; + +import io.ipfs.cid.Cid; + +public interface GetThumbnailForCid { + public Drawable getThumbnail(Cid cid); +} diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 45e1783c1..1cfa53ff8 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -72,6 +72,10 @@ public class Contact implements ListItem, Blockable { private long mLastseen = 0; private String mLastPresence = null; private RtpCapability.Capability rtpCapability; + public Contact(Contact other) { + this(other.getAccount().getUuid(), other.systemName, other.serverName, other.presenceName, other.jid, other.subscription, other.photoUri, other.systemAccount, other.keys.toString(), other.getAvatar().sha1sum, other.mLastseen, other.mLastPresence, other.groups.toString(), other.rtpCapability); + setAccount(other.getAccount()); + } public Contact(final String account, final String systemName, final String serverName, final String presenceName, final Jid jid, final int subscription, final String photoUri, diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index 95cd1810b..017e2d6ef 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -4,9 +4,16 @@ import android.annotation.SuppressLint; import android.content.ContentValues; import android.database.Cursor; import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.Html; import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.ImageSpan; import android.util.Log; import android.util.Pair; +import android.view.View; import eu.siacs.conversations.ui.util.QuoteHelper; @@ -14,6 +21,8 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; import com.google.common.io.ByteSource; import com.google.common.primitives.Longs; +import de.monocles.chat.BobTransfer; +import de.monocles.chat.GetThumbnailForCid; import java.net.URI; import java.net.URISyntaxException; @@ -37,7 +46,6 @@ import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; import eu.siacs.conversations.http.URL; import eu.siacs.conversations.services.AvatarService; import eu.siacs.conversations.ui.util.PresenceSelector; -import eu.siacs.conversations.ui.util.QuoteHelper; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; @@ -51,6 +59,7 @@ import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xml.Tag; import eu.siacs.conversations.xml.XmlReader; import eu.siacs.conversations.xmpp.Jid; +import io.ipfs.cid.Cid; public class Message extends AbstractEntity implements AvatarService.Avatarable { @@ -375,10 +384,11 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0"); fallback.addChild("body", "urn:xmpp:fallback:0") .setAttribute("start", "0") - .setAttribute("end", "" + m.body.length()); + .setAttribute("end", "" + m.body.codePointCount(0, m.body.length())); m.addPayload(fallback); return m; } + public Message react(String emoji) { Set emojis = new HashSet<>(); if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(replyId(), null); @@ -918,9 +928,68 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable public static class MergeSeparator { } + public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) { + final Element html = getHtml(); + if (html == null || Build.VERSION.SDK_INT < 24) { + return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim()); + } else { + SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml( + MessageUtils.filterLtrRtl(html.toString()).trim(), + Html.FROM_HTML_MODE_COMPACT, + (source) -> { + try { + if (thumbnailer == null) return fallbackImg; + Cid cid = BobTransfer.cid(new URI(source)); + if (cid == null) return fallbackImg; + Drawable thumbnail = thumbnailer.getThumbnail(cid); + if (thumbnail == null) return fallbackImg; + return thumbnail; + } catch (final URISyntaxException e) { + return fallbackImg; + } + }, + (opening, tag, output, xmlReader) -> {} + )); + + // Make images clickable and long-clickable with BetterLinkMovementMethod + ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class); + for (ImageSpan span : imageSpans) { + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + + ClickableSpan click_span = new ClickableSpan() { + @Override + public void onClick(View widget) { } + }; + + spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // https://stackoverflow.com/a/10187511/8611 + int i = spannable.length(); + while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { } + return (SpannableStringBuilder) spannable.subSequence(0, i+1); + } + } + + public Element getHtml() { + if (this.payloads == null) return null; + + for (Element el : this.payloads) { + if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) { + return el.getChildren().get(0); + } + } + + return null; + } public SpannableStringBuilder getMergedBody() { - SpannableStringBuilder body = new SpannableStringBuilder(MessageUtils.filterLtrRtl(this.body).trim()); + return getMergedBody(null, null); + } + + public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) { + SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg); Message current = this; while (current.mergeable(current.next())) { current = current.next(); @@ -928,8 +997,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable break; } body.append("\n\n"); - body.setSpan(new MergeSeparator(), body.length() - 2, body.length(), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - body.append(MessageUtils.filterLtrRtl(current.getBody()).trim()); + body.setSpan(new MergeSeparator(), body.length() - 2, body.length(), + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + body.append(current.getSpannableBody(thumbnailer, fallbackImg)); } return body; } diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 66e18476a..6e66704af 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -51,6 +51,7 @@ import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.PresenceTemplate; import eu.siacs.conversations.entities.Roster; @@ -63,6 +64,7 @@ import eu.siacs.conversations.utils.Resolver; import eu.siacs.conversations.xmpp.InvalidJid; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.mam.MamReference; +import io.ipfs.cid.Cid; public class DatabaseBackend extends SQLiteOpenHelper { @@ -1051,6 +1053,43 @@ public class DatabaseBackend extends SQLiteOpenHelper { return filesPaths; } + public DownloadableFile getFileForCid(Cid cid) { + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.query("cheogram.cids", new String[]{"path"}, "cid=?", new String[]{cid.toString()}, null, null, null); + DownloadableFile f = null; + if (cursor.moveToNext()) { + f = new DownloadableFile(cursor.getString(0)); + } + cursor.close(); + return f; + } + + public boolean isBlockedMedia(Cid cid) { + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.query("cheogram.blocked_media", new String[]{"count(*)"}, "cid=?", new String[]{cid.toString()}, null, null, null); + boolean is = false; + if (cursor.moveToNext()) { + is = cursor.getInt(0) > 0; + } + cursor.close(); + return is; + } + + public void saveCid(Cid cid, File file) { + saveCid(cid, file, null); + } + + public void saveCid(Cid cid, File file, String url) { + SQLiteDatabase db = this.getWritableDatabase(); + ContentValues cv = new ContentValues(); + cv.put("cid", cid.toString()); + if (file != null) cv.put("path", file.getAbsolutePath()); + if (url != null) cv.put("url", url); + if (db.update("monocles.cids", cv, "cid=?", new String[]{cid.toString()}) < 1) { + db.insertWithOnConflict("monocles.cids", null, cv, SQLiteDatabase.CONFLICT_REPLACE); + } + } + public static class FilePath { public final UUID uuid; public final String path; diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index e6f6e2708..1849cd1c1 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -50,6 +50,7 @@ import androidx.exifinterface.media.ExifInterface; import com.google.common.base.Strings; import com.google.common.io.ByteStreams; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.File; @@ -98,6 +99,7 @@ import eu.siacs.conversations.utils.MimeUtils; import eu.siacs.conversations.xmpp.pep.Avatar; import ezvcard.Ezvcard; import ezvcard.VCard; +import io.ipfs.cid.Cid; import me.drakeet.support.toast.ToastCompat; public class FileBackend { @@ -1594,8 +1596,10 @@ public class FileBackend { public void updateFileParams(Message message) { updateFileParams(message, null); } - public void updateFileParams(final Message message, final String url) { + updateFileParams(message, url, true); + } + public void updateFileParams(final Message message, String url, boolean updateCids) { final boolean encrypted = message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED; @@ -1953,6 +1957,27 @@ public class FileBackend { } } + public File getStorageLocation(final InputStream is, final String extension) throws IOException, XmppConnectionService.BlockedMediaException { + final String mime = MimeUtils.guessMimeTypeFromExtension(extension); + Cid[] cids = calculateCids(is); + + File file = getStorageLocation(String.format("%s.%s", cids[0], extension), mime); + for (int i = 0; i < cids.length; i++) { + mXmppConnectionService.saveCid(cids[i], file); + } + return file; + } + public Cid[] calculateCids(final Uri uri) throws IOException { + return calculateCids(mXmppConnectionService.getContentResolver().openInputStream(uri)); + } + + public Cid[] calculateCids(final InputStream is) throws IOException { + try { + return CryptoHelper.cid(is, new String[]{"SHA-256", "SHA-1", "SHA-512"}); + } catch (final NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } private static class Dimensions { public final int width; public final int height; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index f85cc30c4..94f96ed66 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -71,6 +71,7 @@ import net.java.otr4j.session.SessionID; import net.java.otr4j.session.SessionImpl; import net.java.otr4j.session.SessionStatus; +import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.utils.ThemeHelper; import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import eu.siacs.conversations.xmpp.Jid; @@ -210,6 +211,7 @@ import eu.siacs.conversations.xmpp.pep.PublishOptions; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; import eu.siacs.conversations.xmpp.stanzas.PresencePacket; +import io.ipfs.cid.Cid; import me.leolin.shortcutbadger.ShortcutBadger; public class XmppConnectionService extends Service { @@ -1453,7 +1455,7 @@ public class XmppConnectionService extends Service { Resolver.init(this); this.mRandom = new SecureRandom(); updateMemorizingTrustmanager(); - final int DEFAULT_CACHE_SIZE_PROPORTION = 8; + final int DEFAULT_CACHE_SIZE_PROPORTION = 10; ActivityManager manager = (ActivityManager) this.getSystemService(Context.ACTIVITY_SERVICE); int memoryClass = manager.getMemoryClass(); int memoryClassInKilobytes = memoryClass * 1024; @@ -5623,6 +5625,21 @@ public class XmppConnectionService extends Service { } } + public DownloadableFile getFileForCid(Cid cid) { + return this.databaseBackend.getFileForCid(cid); + } + + public void saveCid(Cid cid, File file) throws BlockedMediaException { + saveCid(cid, file, null); + } + + public void saveCid(Cid cid, File file, String url) throws BlockedMediaException { + if (this.databaseBackend.isBlockedMedia(cid)) { + throw new BlockedMediaException(); + } + this.databaseBackend.saveCid(cid, file, url); + } + public interface OnMamPreferencesFetched { void onPreferencesFetched(Element prefs); @@ -5779,4 +5796,5 @@ public class XmppConnectionService extends Service { } }).start(); } + public static class BlockedMediaException extends Exception { } } \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 05ef0ec9b..b74fbea03 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -82,6 +82,7 @@ import androidx.core.content.ContextCompat; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.databinding.DataBindingUtil; +import android.text.SpannableStringBuilder; import com.google.common.base.Optional; @@ -101,6 +102,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; import eu.siacs.conversations.utils.TimeFrameUtils; import eu.siacs.conversations.xml.Element; @@ -972,7 +974,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke return; } final Editable text = this.binding.textinput.getText(); - final String body = text == null ? "" : text.toString(); + String body = text == null ? "" : text.toString(); final Conversation conversation = this.conversation; if (body.length() == 0 || conversation == null) { return; @@ -982,6 +984,11 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } final Message message; if (conversation.getCorrectingMessage() == null) { + boolean attention = false; + if (Pattern.compile("\\A@here\\s.*").matcher(body).find()) { + attention = true; + body = body.replaceFirst("\\A@here\\s+", ""); + } if (conversation.getReplyTo() != null) { if (Emoticons.isEmoji(body)) { message = conversation.getReplyTo().react(body); @@ -994,11 +1001,15 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke message = new Message(conversation, body, conversation.getNextEncryption()); } message.setThread(conversation.getThread()); + if (attention) { + message.addPayload(new Element("attention", "urn:xmpp:attention:0")); + } Message.configurePrivateMessage(message); } else { message = conversation.getCorrectingMessage(); - message.putEdited(message.getUuid(), message.getServerMsgId(), message.getBody(), message.getTimeSent()); message.setBody(body); + message.setThread(conversation.getThread()); + message.putEdited(message.getUuid(), message.getServerMsgId(), message.getBody(), message.getTimeSent()); message.setServerMsgId(null); message.setUuid(UUID.randomUUID().toString()); } @@ -1012,6 +1023,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke default: sendMessage(message); } + setupReply(null); } private boolean trustKeysIfNeeded(final Conversation conversation, final int requestCode) { @@ -1353,6 +1365,9 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke binding.textinput.setRichContentListener(new String[]{"image/*"}, mEditorContentListener); binding.textSendButton.setOnClickListener(this.mSendButtonListener); + binding.contextPreviewCancel.setOnClickListener((v) -> { + setupReply(null); + }); binding.textSendButton.setOnLongClickListener(this.mSendButtonLongListener); binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener); binding.recordVoiceButton.setOnClickListener(this.mRecordVoiceButtonListener); @@ -1447,18 +1462,23 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke private void quoteMessage(Message message, @Nullable String user) { if (message.isGeoUri()) { quoteGeoUri(message, user); - } else if (message.isFileOrImage()) { - quoteMedia(message, user); - } else if (message.isTypeText()) { - final StringBuilder stringBuilder = new StringBuilder(); - if (activity.showDateInQuotes()) { - stringBuilder.append(df.format(message.getTimeSent())).append(System.getProperty("line.separator")); - } - stringBuilder.append(MessageUtils.prepareQuote(message)); - quoteText(stringBuilder.toString(), user); } + setupReply(message); } + private void setupReply(Message message) { + conversation.setReplyTo(message); + if (message == null) { + binding.contextPreview.setVisibility(View.GONE); + return; + } + + SpannableStringBuilder body = message.getSpannableBody(null, null); + if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️"); + messageListAdapter.handleTextQuotes(body, activity.isDarkTheme()); + binding.contextPreviewText.setText(body); + binding.contextPreview.setVisibility(View.VISIBLE); + } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { //This should cancel any remaining click events that would otherwise trigger links diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 1f3174896..a728b9a1c 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -997,9 +997,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne this.contacts.add(contact); } } - final Contact self = account.getSelfContact(); + final Contact self = new Contact(account.getSelfContact()); + self.setSystemName("Note to Self"); if (self.match(this, needle)) { - self.setSystemName(getString(R.string.note_to_self)); this.contacts.add(self); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index 090422946..d457e8414 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -576,7 +576,7 @@ public class MessageAdapter extends ArrayAdapter { * Applies QuoteSpan to group of lines which starts with > or » characters. * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text. */ - private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) { + public boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) { boolean startsWithQuote = false; int quoteDepth = 1; while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) { diff --git a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java index 53700ac99..8346ed822 100644 --- a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java @@ -10,6 +10,8 @@ import org.bouncycastle.asn1.x500.style.BCStyle; import org.bouncycastle.asn1.x500.style.IETFUtils; import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import java.io.IOException; +import java.io.InputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -30,6 +32,8 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.xmpp.Jid; +import io.ipfs.cid.Cid; +import io.ipfs.multihash.Multihash; public final class CryptoHelper { @@ -283,4 +287,55 @@ public final class CryptoHelper { final String u = url.toLowerCase(); return !u.contains(" ") && (u.startsWith("https://") || u.startsWith("http://") || u.startsWith("p1s3://")) && u.endsWith(".pgp"); } + + + public static String multihashAlgo(Multihash.Type type) throws NoSuchAlgorithmException { + switch(type) { + case sha1: + return "sha-1"; + case sha2_256: + return "sha-256"; + case sha2_512: + return "sha-512"; + default: + throw new NoSuchAlgorithmException("" + type); + } + } + + public static Multihash.Type multihashType(String algo) throws NoSuchAlgorithmException { + if (algo.equals("SHA-1") || algo.equals("sha-1") || algo.equals("sha1")) { + return Multihash.Type.sha1; + } else if (algo.equals("SHA-256") || algo.equals("sha-256")) { + return Multihash.Type.sha2_256; + } else if (algo.equals("SHA-512") | algo.equals("sha-512")) { + return Multihash.Type.sha2_512; + } else { + throw new NoSuchAlgorithmException(algo); + } + } + + public static Cid cid(byte[] digest, String algo) throws NoSuchAlgorithmException { + return Cid.buildCidV1(Cid.Codec.Raw, multihashType(algo), digest); + } + + public static Cid[] cid(InputStream in, String[] algo) throws NoSuchAlgorithmException, IOException { + byte[] buf = new byte[4096]; + int len; + MessageDigest[] md = new MessageDigest[algo.length]; + for (int i = 0; i < md.length; i++) { + md[i] = MessageDigest.getInstance(algo[i]); + } + while ((len = in.read(buf)) != -1) { + for (int i = 0; i < md.length; i++) { + md[i].update(buf, 0, len); + } + } + + Cid[] cid = new Cid[md.length]; + for (int i = 0; i < cid.length; i++) { + cid[i] = cid(md[i].digest(), algo[i]); + } + + return cid; + } } \ No newline at end of file diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml index ebdd75c0a..0f94196be 100644 --- a/src/main/res/layout/fragment_conversation.xml +++ b/src/main/res/layout/fragment_conversation.xml @@ -53,6 +53,44 @@ android:visibility="gone" app:backgroundColor="?attr/colorAccent" /> + + + + + + + + + android:paddingBottom="2dp" + android:background="?attr/color_background_primary"> + Outgoing call · %s Note to self Retract message + Reply to