diff --git a/build.gradle b/build.gradle index 66297a578c1f365e1586e21b339c66e131a2a859..437740c02c77225f88c0e47194a6438b53b39bbe 100644 --- a/build.gradle +++ b/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' diff --git a/src/cheogram/res/values/strings.xml b/src/cheogram/res/values/strings.xml index d4bc883598daf9b0b2d61f4d067b5ab49140155c..f30ca68a50e84a3e0839d28eadac6565cd81b844 100644 --- a/src/cheogram/res/values/strings.xml +++ b/src/cheogram/res/values/strings.xml @@ -28,4 +28,6 @@ Show only this thread Use Phone Accounts for Incoming Calls Incoming calls from phone numbers may ring with your system dialler instead of this app\'s notification settings + Save as Sticker + Sticker Name diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index e43755c90e913cc3565c1e852f868052f74d4d66..62e2001c9a059e38ab1516562e644a507737c756 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/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"); diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 810a8f2b4a6839731e5b57c2a50be9971a385f5b..2748fac69fdc991827a9d1f33fc19f7fdf9ba4a7 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/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, diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 0357d6e8164a87997209d0b09f235d8054107cb7..50721d9dfeff1defbae6dba280f6b20445ab97c2 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/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); diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 998693f8521cabfc380eff72c95405fffbee7236..93a0c11fc7f065f14776890532b9b21589be59b4 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/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() { 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 a6e53d77a3b45713efd628a1d1c8511185e98c00..cf126078c807a454d4bf943dda0dab8f56912d66 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/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 { 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 { 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 { 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 { void onContactPictureLongClicked(View v, Message message); } + public interface OnInlineImageLongClicked { + boolean onInlineImageLongClicked(Cid cid); + } + private static class ViewHolder { public Button load_more_messages; diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml index 34b8724adf5df6c1ff96ff06ba7d4ef4632fceb5..693674571af7e1dc7464f6936bc3011c9039410e 100644 --- a/src/main/res/menu/message_context.xml +++ b/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" /> + + + +