Basic link previews

Stephen Paul Weber created

When the link is to a media file, just attach it

Change summary

src/main/java/eu/siacs/conversations/entities/Message.java               | 13 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java     | 24 
src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java     | 29 
src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java    |  2 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java | 74 
src/main/res/values/bools.xml                                            |  3 
src/main/res/values/strings.xml                                          |  2 
src/main/res/xml/preferences.xml                                         |  6 
8 files changed, 133 insertions(+), 20 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -47,6 +47,7 @@ import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 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.MyLinkify;
 import eu.siacs.conversations.ui.util.PresenceSelector;
 import eu.siacs.conversations.ui.util.QuoteHelper;
 import eu.siacs.conversations.utils.CryptoHelper;
@@ -788,7 +789,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             return false;
         } else {
             String body, otherBody;
-            if (this.hasFileOnRemoteHost()) {
+            if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
                 body = getFileParams().url;
                 otherBody = message.body == null ? null : message.body.trim();
             } else {
@@ -1081,6 +1082,16 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         }
     }
 
+    public List<URI> getLinks() {
+        return MyLinkify.extractLinks(new SpannableStringBuilder(getBody())).stream().map((url) -> {
+            try {
+                return new URI(url);
+            } catch (final URISyntaxException e) {
+                return null;
+            }
+        }).filter(x -> x != null).collect(Collectors.toList());
+    }
+
     public URI getOob() {
         final String url = getFileParams().url;
         try {

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java 🔗

@@ -106,17 +106,19 @@ public class MessageGenerator extends AbstractGenerator {
         if (message.hasFileOnRemoteHost()) {
             final Message.FileParams fileParams = message.getFileParams();
 
-            if (message.getBody().equals("")) {
-                message.setBody(fileParams.url);
-                packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB)
-                      .addChild("body", "urn:xmpp:fallback:0");
-            } else {
-                long start = message.getQuoteableBody().length();
-                message.appendBody(fileParams.url);
-                packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB)
-                      .addChild("body", "urn:xmpp:fallback:0")
-                          .setAttribute("start", String.valueOf(start))
-                          .setAttribute("end", String.valueOf(start + fileParams.url.length()));
+            if (message.getFallbacks(Namespace.OOB).isEmpty()) {
+                if (message.getBody().equals("")) {
+                    message.setBody(fileParams.url);
+                    packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB)
+                        .addChild("body", "urn:xmpp:fallback:0");
+                } else {
+                    long start = message.getQuoteableBody().length();
+                    message.appendBody(fileParams.url);
+                    packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB)
+                        .addChild("body", "urn:xmpp:fallback:0")
+                            .setAttribute("start", String.valueOf(start))
+                            .setAttribute("end", String.valueOf(start + fileParams.url.length()));
+                }
             }
 
             packet.addChild("x", Namespace.OOB).addChild("url").setContent(fileParams.url);

src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java 🔗

@@ -131,7 +131,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         }
     }
 
-    OkHttpClient buildHttpClient(final HttpUrl url, final Account account, boolean interactive) {
+    public OkHttpClient buildHttpClient(final HttpUrl url, final Account account, boolean interactive) {
         return buildHttpClient(url, account, 30, interactive);
     }
 
@@ -182,4 +182,31 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         }
         return body.byteStream();
     }
+
+    public static String extractFilenameFromResponse(okhttp3.Response response) {
+        String filename = null;
+
+        // Try to extract filename from the Content-Disposition header
+        String contentDisposition = response.header("Content-Disposition");
+        if (contentDisposition != null && contentDisposition.contains("filename=")) {
+            String[] parts = contentDisposition.split(";");
+            for (String part : parts) {
+                if (part.trim().startsWith("filename=")) {
+                    filename = part.substring("filename=".length()).trim().replace("\"", "");
+                    break;
+                }
+            }
+        }
+
+        // If filename is not found in the Content-Disposition header, try to get it from the URL
+        if (filename == null || filename.isEmpty()) {
+            HttpUrl httpUrl = response.request().url();
+            List<String> pathSegments = httpUrl.pathSegments();
+            if (!pathSegments.isEmpty()) {
+                filename = pathSegments.get(pathSegments.size() - 1);
+            }
+        }
+
+        return filename;
+    }
 }

src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java 🔗

@@ -213,7 +213,6 @@ public class HttpDownloadConnection implements Transferable {
             message.setDeleted(true);
         }
         message.setTransferable(null);
-        if (cb != null) cb.accept(file);
         mXmppConnectionService.updateMessage(message);
         mHttpConnectionManager.finishConnection(this);
         final boolean notifyAfterScan = notify;
@@ -399,6 +398,7 @@ public class HttpDownloadConnection implements Transferable {
                 decryptIfNeeded();
                 finish();
                 updateImageBounds();
+                if (cb != null) cb.accept(file);
             } catch (final SSLHandshakeException e) {
                 changeStatus(STATUS_OFFER);
             } catch (final Exception e) {

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -71,6 +71,7 @@ import org.openintents.openpgp.util.OpenPgpServiceConnection;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.net.URI;
 import java.security.Security;
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
@@ -186,6 +187,9 @@ import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 import eu.siacs.conversations.xmpp.stanzas.PresencePacket;
 import me.leolin.shortcutbadger.ShortcutBadger;
 
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+
 public class XmppConnectionService extends Service {
 
     public static final String ACTION_REPLY_TO_CONVERSATION = "reply_to_conversations";
@@ -1638,10 +1642,10 @@ public class XmppConnectionService extends Service {
     }
 
     public void sendMessage(final Message message) {
-        sendMessage(message, false, false);
+        sendMessage(message, false, false, false);
     }
 
-    private void sendMessage(final Message message, final boolean resend, final boolean delay) {
+    private void sendMessage(final Message message, final boolean resend, final boolean previewedLinks, final boolean delay) {
         final Account account = message.getConversation().getAccount();
         if (account.setShowErrorNotification(true)) {
             databaseBackend.updateAccount(account);
@@ -1676,7 +1680,65 @@ public class XmppConnectionService extends Service {
             message.setCounterpart(message.getConversation().getJid().asBareJid());
         }
 
-        if (account.isOnlineAndConnected() && !inProgressJoin) {
+        boolean waitForPreview = false;
+        if (getPreferences().getBoolean("send_link_previews", true) && !previewedLinks && !message.needsUploading()) {
+            final List<URI> links = message.getLinks();
+            if (!links.isEmpty()) {
+                waitForPreview = true;
+                if (account.isOnlineAndConnected()) {
+                    FILE_ATTACHMENT_EXECUTOR.execute(() -> {
+                        for (URI link : links) {
+                            if ("https".equals(link.getScheme())) {
+                                try {
+                                    HttpUrl url = HttpUrl.parse(link.toString());
+                                    OkHttpClient http = getHttpConnectionManager().buildHttpClient(url, account, false);
+                                    okhttp3.Response response = http.newCall(new okhttp3.Request.Builder().url(url).head().build()).execute();
+                                    final String mimeType = response.header("Content-Type") == null ? "" : response.header("Content-Type");
+                                    final boolean image = mimeType.startsWith("image/");
+                                    final boolean audio = mimeType.startsWith("audio/");
+                                    final boolean video = mimeType.startsWith("video/");
+                                    final boolean pdf = mimeType.equals("application/pdf");
+                                    if (response.isSuccessful() && (image || audio || video || pdf)) {
+                                        Message.FileParams params = message.getFileParams();
+                                        params.url = url.toString();
+                                        if (response.header("Content-Length") != null) params.size = Long.parseLong(response.header("Content-Length"), 10);
+                                        if (!Message.configurePrivateFileMessage(message)) {
+                                            message.setType(image ? Message.TYPE_IMAGE : Message.TYPE_FILE);
+                                        }
+                                        params.setName(HttpConnectionManager.extractFilenameFromResponse(response));
+
+                                        if (link.toString().equals(message.getQuoteableBody())) {
+                                            Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB);
+                                            fallback.addChild("body", "urn:xmpp:fallback:0");
+                                            message.addPayload(fallback);
+                                        } else if (message.getQuoteableBody().indexOf(link.toString()) >= 0) {
+                                            // Part of the real body, not just a fallback
+                                            Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB);
+                                            fallback.addChild("body", "urn:xmpp:fallback:0")
+                                                .setAttribute("start", "0")
+                                                .setAttribute("end", "0");
+                                            message.addPayload(fallback);
+                                        }
+
+                                        getHttpConnectionManager().createNewDownloadConnection(message, false, (file) -> {
+                                            synchronized (message.getConversation()) {
+                                                if (message.getStatus() == Message.STATUS_WAITING) sendMessage(message, true, true, false);
+                                            }
+                                        });
+                                        return;
+                                    }
+                                } catch (final IOException e) {  }
+                            }
+                        }
+                        synchronized (message.getConversation()) {
+                            if (message.getStatus() == Message.STATUS_WAITING) sendMessage(message, true, true, false);
+                        }
+                    });
+                }
+            }
+        }
+
+        if (account.isOnlineAndConnected() && !inProgressJoin && !waitForPreview) {
             switch (message.getEncryption()) {
                 case Message.ENCRYPTION_NONE:
                     if (message.needsUploading()) {
@@ -1822,11 +1884,13 @@ public class XmppConnectionService extends Service {
     }
 
     private void sendUnsentMessages(final Conversation conversation) {
-        conversation.findWaitingMessages(message -> resendMessage(message, true));
+        synchronized (conversation) {
+            conversation.findWaitingMessages(message -> resendMessage(message, true));
+        }
     }
 
     public void resendMessage(final Message message, final boolean delay) {
-        sendMessage(message, true, delay);
+        sendMessage(message, true, false, delay);
     }
 
     public boolean isOnboarding() {

src/main/res/values/bools.xml 🔗

@@ -1,4 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <bool name="show_avatar_incoming_call">true</bool>
-</resources>
+    <bool name="send_link_previews">true</bool>
+</resources>

src/main/res/values/strings.xml 🔗

@@ -1010,6 +1010,8 @@
     <string name="pref_up_push_account_summary">The account through which push messages will be received.</string>
     <string name="pref_up_push_server_title">Push Server</string>
     <string name="pref_up_push_server_summary">A user-chosen push server to relay push messages to apps on your device.</string>
+    <string name="pref_send_link_previews">Send link previews</string>
+    <string name="pref_send_link_previews_summary">Attach metadata about links when sending a message</string>
     <string name="no_account_deactivated">None (deactivated)</string>
     <string name="decline">Decline</string>
     <string name="delete_from_server">Remove account from server</string>

src/main/res/xml/preferences.xml 🔗

@@ -35,6 +35,12 @@
             android:summary="@string/pref_broadcast_last_activity_summary"
             android:title="@string/pref_broadcast_last_activity" />
 
+        <CheckBoxPreference
+            android:defaultValue="@bool/send_link_previews"
+            android:key="send_link_previews"
+            android:summary="@string/pref_send_link_previews_summary"
+            android:title="@string/pref_send_link_previews" />
+
         <CheckBoxPreference
             android:defaultValue="@bool/prevent_screenshots"
             android:key="prevent_screenshots"