Merge branch 'save-as-sticker'

Stephen Paul Weber created

* save-as-sticker:
  Allow saving inline images as sticker by long press
  Allow changing sticker directory
  Context menu item to save an image as a sticker

Change summary

build.gradle                                                        |  1 
src/cheogram/res/values/strings.xml                                 |  2 
src/main/java/eu/siacs/conversations/entities/Message.java          | 25 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java   | 29 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java   | 71 
src/main/java/eu/siacs/conversations/ui/SettingsActivity.java       | 15 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java | 32 
src/main/res/menu/message_context.xml                               |  4 
src/main/res/xml/preferences.xml                                    |  5 
9 files changed, 182 insertions(+), 2 deletions(-)

Detailed changes

build.gradle 🔗

@@ -95,6 +95,7 @@ dependencies {
     implementation 'io.michaelrocks:libphonenumber-android:8.12.49'
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
     implementation 'androidx.recyclerview:recyclerview:1.1.0'
+    implementation 'androidx.documentfile:documentfile:1.0.1'
     implementation 'com.github.ipld:java-cid:v1.3.1'
     implementation 'com.splitwise:tokenautocomplete:3.0.2'
     implementation 'me.saket:better-link-movement-method:2.2.0'

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

@@ -28,4 +28,6 @@
     <string name="only_this_thread">Show only this thread</string>
     <string name="pref_dialler_integration_incoming">Use Phone Accounts for Incoming Calls</string>
     <string name="pref_dialler_integration_incoming_summary">Incoming calls from phone numbers may ring with your system dialler instead of this app\'s notification settings</string>
+    <string name="save_as_sticker">Save as Sticker</string>
+    <string name="sticker_name">Sticker Name</string>
 </resources>

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

@@ -7,9 +7,13 @@ import android.graphics.Color;
 import android.os.Build;
 import android.text.Html;
 import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ImageSpan;
+import android.text.style.ClickableSpan;
 import android.util.Base64;
 import android.util.Log;
 import android.util.Pair;
+import android.view.View;
 
 import com.cheogram.android.BobTransfer;
 import com.cheogram.android.GetThumbnailForCid;
@@ -854,6 +858,20 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 (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))) { }
@@ -1188,6 +1206,13 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             return size == null ? 0 : size;
         }
 
+        public String getName() {
+            Element file = getFileElement();
+            if (file == null) return null;
+
+            return file.findChildContent("name", file.getNamespace());
+        }
+
         public Element toSims() {
             if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
             sims.setAttribute("type", "data");

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -35,6 +35,7 @@ import android.util.LruCache;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.StringRes;
 import androidx.core.content.FileProvider;
+import androidx.documentfile.provider.DocumentFile;
 import androidx.exifinterface.media.ExifInterface;
 
 import com.cheogram.android.BobTransfer;
@@ -673,6 +674,34 @@ public class FileBackend {
         return FileUtils.getPath(mXmppConnectionService, uri);
     }
 
+    public void copyFileToDocumentFile(Context ctx, File file, DocumentFile df, String name) throws FileCopyException {
+        Log.d(
+                Config.LOGTAG,
+                "copy file (" + file + ") to " + df + " / " + name);
+        final DocumentFile dff = df.createFile(MimeUtils.guessMimeTypeFromUri(ctx, getUriForFile(ctx, file)), name);
+        try (final InputStream is = new FileInputStream(file);
+                final OutputStream os =
+                        mXmppConnectionService.getContentResolver().openOutputStream(dff.getUri())) {
+            if (is == null) {
+                throw new FileCopyException(R.string.error_file_not_found);
+            }
+            try {
+                ByteStreams.copy(is, os);
+                os.flush();
+            } catch (IOException e) {
+                throw new FileWriterException(file);
+            }
+        } catch (final FileNotFoundException e) {
+            throw new FileCopyException(R.string.error_file_not_found);
+        } catch (final FileWriterException e) {
+            throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
+        } catch (final SecurityException | IllegalStateException e) {
+            throw new FileCopyException(R.string.error_security_exception);
+        } catch (final IOException e) {
+            throw new FileCopyException(R.string.error_io_exception);
+        }
+    }
+
     private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException {
         Log.d(
                 Config.LOGTAG,

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -23,7 +23,9 @@ import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.Environment;
 import android.os.Handler;
+import android.os.storage.StorageManager;
 import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.MediaStore;
@@ -62,6 +64,7 @@ import androidx.appcompat.app.AlertDialog;
 import androidx.core.view.inputmethod.InputConnectionCompat;
 import androidx.core.view.inputmethod.InputContentInfoCompat;
 import androidx.databinding.DataBindingUtil;
+import androidx.documentfile.provider.DocumentFile;
 import androidx.viewpager.widget.PagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 
@@ -72,6 +75,9 @@ import com.google.common.collect.ImmutableList;
 
 import org.jetbrains.annotations.NotNull;
 
+import io.ipfs.cid.Cid;
+
+import java.io.File;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -155,7 +161,8 @@ import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 public class ConversationFragment extends XmppFragment
         implements EditMessage.KeyboardListener,
                 MessageAdapter.OnContactPictureLongClicked,
-                MessageAdapter.OnContactPictureClicked {
+                MessageAdapter.OnContactPictureClicked,
+                MessageAdapter.OnInlineImageLongClicked {
 
     public static final int REQUEST_SEND_MESSAGE = 0x0201;
     public static final int REQUEST_DECRYPT_PGP = 0x0202;
@@ -167,6 +174,7 @@ public class ConversationFragment extends XmppFragment
     public static final int REQUEST_COMMIT_ATTACHMENTS = 0x0212;
     public static final int REQUEST_START_AUDIO_CALL = 0x213;
     public static final int REQUEST_START_VIDEO_CALL = 0x214;
+    public static final int REQUEST_SAVE_STICKER = 0x215;
     public static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301;
     public static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302;
     public static final int ATTACHMENT_CHOICE_CHOOSE_FILE = 0x0303;
@@ -206,6 +214,8 @@ public class ConversationFragment extends XmppFragment
     private ConversationsActivity activity;
     private boolean reInitRequiredOnStart = true;
     private int identiconWidth = -1;
+    private File savingAsSticker = null;
+    private String savingAsStickerName = null;
     private final OnClickListener clickToMuc =
             new OnClickListener() {
 
@@ -986,6 +996,24 @@ public class ConversationFragment extends XmppFragment
 
     private void handlePositiveActivityResult(int requestCode, final Intent data) {
         switch (requestCode) {
+            case REQUEST_SAVE_STICKER:
+                final DocumentFile df = DocumentFile.fromTreeUri(activity, data.getData());
+                final File f = savingAsSticker;
+                final String existingName = savingAsStickerName;
+                savingAsSticker = null;
+                savingAsStickerName = null;
+                activity.quickEdit(existingName, R.string.sticker_name, (name) -> {
+                    try {
+                        activity.xmppConnectionService.getFileBackend().copyFileToDocumentFile(activity, f, df, name);
+                    } catch (final FileBackend.FileCopyException e) {
+                        Toast.makeText(activity, e.getResId(), Toast.LENGTH_SHORT).show();
+                        return null;
+                    }
+
+                    Toast.makeText(activity, "Sticker saved", Toast.LENGTH_SHORT).show();
+                    return null;
+                });
+                break;
             case REQUEST_TRUST_KEYS_TEXT:
                 sendMessage();
                 break;
@@ -1257,6 +1285,7 @@ public class ConversationFragment extends XmppFragment
         messageListAdapter = new MessageAdapter((XmppActivity) getActivity(), this.messageList);
         messageListAdapter.setOnContactPictureClicked(this);
         messageListAdapter.setOnContactPictureLongClicked(this);
+        messageListAdapter.setOnInlineImageLongClicked(this);
         binding.messagesView.setAdapter(messageListAdapter);
 
         registerForContextMenu(binding.messagesView);
@@ -1315,6 +1344,7 @@ public class ConversationFragment extends XmppFragment
         Log.d(Config.LOGTAG, "ConversationFragment.onDestroyView()");
         messageListAdapter.setOnContactPictureClicked(null);
         messageListAdapter.setOnContactPictureLongClicked(null);
+        messageListAdapter.setOnInlineImageLongClicked(null);
         if (conversation != null) conversation.setupViewPager(null, null);
     }
 
@@ -1405,6 +1435,7 @@ public class ConversationFragment extends XmppFragment
             MenuItem shareWith = menu.findItem(R.id.share_with);
             MenuItem sendAgain = menu.findItem(R.id.send_again);
             MenuItem copyUrl = menu.findItem(R.id.copy_url);
+            MenuItem saveAsSticker = menu.findItem(R.id.save_as_sticker);
             MenuItem downloadFile = menu.findItem(R.id.download_file);
             MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
             MenuItem deleteFile = menu.findItem(R.id.delete_file);
@@ -1467,6 +1498,7 @@ public class ConversationFragment extends XmppFragment
                 if (path == null
                         || !path.startsWith("/")
                         || FileBackend.inConversationsDirectory(requireActivity(), path)) {
+                    saveAsSticker.setVisible(true);
                     deleteFile.setVisible(true);
                     deleteFile.setTitle(
                             activity.getString(
@@ -1523,6 +1555,9 @@ public class ConversationFragment extends XmppFragment
             case R.id.copy_url:
                 ShareUtil.copyUrlToClipboard(activity, selectedMessage);
                 return true;
+            case R.id.save_as_sticker:
+                saveAsSticker(selectedMessage);
+                return true;
             case R.id.download_file:
                 startDownloadable(selectedMessage);
                 return true;
@@ -2296,6 +2331,40 @@ public class ConversationFragment extends XmppFragment
         builder.create().show();
     }
 
+    public boolean onInlineImageLongClicked(Cid cid) {
+        DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
+        if (f == null) return false;
+
+        saveAsSticker(f, null);
+        return true;
+    }
+
+    private void saveAsSticker(final Message m) {
+        String existingName = m.getFileParams() != null && m.getFileParams().getName() != null ? m.getFileParams().getName() : "";
+        existingName = existingName.lastIndexOf(".") == -1 ? existingName : existingName.substring(0, existingName.lastIndexOf("."));
+        saveAsSticker(activity.xmppConnectionService.getFileBackend().getFile(m), existingName);
+    }
+
+    private void saveAsSticker(final File file, final String name) {
+        savingAsSticker = file;
+        savingAsStickerName = name;
+
+        Intent intent = ((StorageManager) activity.getSystemService(Context.STORAGE_SERVICE)).getPrimaryStorageVolume().createOpenDocumentTreeIntent();
+
+        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
+        final String dir = p.getString("sticker_directory", "Stickers");
+        if (dir.startsWith("content://")) {
+            intent.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(dir));
+        } else {
+            new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/" + dir + "/User Pack").mkdirs();
+            Uri uri = intent.getParcelableExtra("android.provider.extra.INITIAL_URI");
+            intent.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(uri.toString().replace("/root/", "/document/") + "%3APictures%2F" + dir));
+        }
+
+        Toast.makeText(activity, "Choose a sticker pack to add this sticker to", Toast.LENGTH_SHORT).show();
+        startActivityForResult(Intent.createChooser(intent, "Choose sticker pack"), REQUEST_SAVE_STICKER);
+    }
+
     private void deleteFile(final Message message) {
         AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity());
         builder.setNegativeButton(R.string.cancel, null);

src/main/java/eu/siacs/conversations/ui/SettingsActivity.java 🔗

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.ui;
 
 import android.app.FragmentManager;
+import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.SharedPreferences;
@@ -9,6 +10,7 @@ import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.storage.StorageManager;
 import android.preference.CheckBoxPreference;
 import android.preference.ListPreference;
 import android.preference.Preference;
@@ -91,6 +93,12 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
         configureActionBar(getSupportActionBar());
     }
 
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(this);
+        p.edit().putString("sticker_directory", data.getData().toString()).commit();
+    }
+
     @Override
     void onBackendConnected() {
         boolean diallerIntegrationPossible = false;
@@ -363,6 +371,13 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
                 privacyCategory.removePreference(omemoPreference);
             }
         }
+
+        final Preference stickerDir = mSettingsFragment.findPreference("sticker_directory");
+        stickerDir.setOnPreferenceClickListener((p) -> {
+            Intent intent = ((StorageManager) getSystemService(Context.STORAGE_SERVICE)).getPrimaryStorageVolume().createOpenDocumentTreeIntent();
+            startActivityForResult(Intent.createChooser(intent, "Choose sticker location"), 0);
+            return true;
+        });
     }
 
     private void changeOmemoSettingSummary() {

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -14,6 +14,8 @@ import android.preference.PreferenceManager;
 import android.text.Spannable;
 import android.text.SpannableString;
 import android.text.SpannableStringBuilder;
+import android.text.style.ImageSpan;
+import android.text.style.ClickableSpan;
 import android.text.format.DateUtils;
 import android.text.style.ForegroundColorSpan;
 import android.text.style.RelativeSizeSpan;
@@ -111,6 +113,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     private OnContactPictureClicked mOnContactPictureClickedListener;
     private OnContactPictureClicked mOnMessageBoxClickedListener;
     private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
+    private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
     private boolean mUseGreenBackground = false;
     private final boolean mForceNames;
 
@@ -162,6 +165,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         this.mOnContactPictureLongClickedListener = listener;
     }
 
+    public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
+        this.mOnInlineImageLongClickedListener = listener;
+    }
+
     @Override
     public int getViewTypeCount() {
         return 5;
@@ -577,7 +584,26 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             MyLinkify.addLinks(body, message.getConversation().getAccount());
             viewHolder.messageBody.setAutoLinkMask(0);
             viewHolder.messageBody.setText(body);
-            BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
+            BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
+                @Override
+                protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
+                    if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
+                        super.dispatchUrlLongClick(tv, span);
+                        return;
+                    }
+
+                    Spannable body = (Spannable) tv.getText();
+                    ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
+                    if (imageSpans.length > 0) {
+                        Uri uri = Uri.parse(imageSpans[0].getSource());
+                        Cid cid = BobTransfer.cid(uri);
+                        if (cid == null) return;
+                        if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
+                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
+                        }
+                    }
+                }
+            };
             method.setOnLinkLongClickListener((tv, url) -> {
                 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
                 ShareUtil.copyLinkToClipboard(activity, url);
@@ -1096,6 +1122,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         void onContactPictureLongClicked(View v, Message message);
     }
 
+    public interface OnInlineImageLongClicked {
+        boolean onInlineImageLongClicked(Cid cid);
+    }
+
     private static class ViewHolder {
 
         public Button load_more_messages;

src/main/res/menu/message_context.xml 🔗

@@ -42,6 +42,10 @@
         android:id="@+id/copy_url"
         android:title="@string/copy_original_url"
         android:visible="false" />
+    <item
+        android:id="@+id/save_as_sticker"
+        android:title="@string/save_as_sticker"
+        android:visible="false" />
     <item
         android:id="@+id/show_error_message"
         android:title="@string/show_error_message"

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

@@ -326,6 +326,11 @@
                     android:summary="@string/pref_scroll_to_bottom_summary"
                     android:title="@string/pref_scroll_to_bottom" />
             </PreferenceCategory>
+            <PreferenceCategory android:key="expert_media" android:title="Media">
+                <Preference
+                    android:title="Change Stickers Location"
+                    android:key="sticker_directory" />
+            </PreferenceCategory>
             <PreferenceCategory android:title="@string/pref_presence_settings">
                 <CheckBoxPreference
                     android:defaultValue="@bool/manually_change_presence"