diff --git a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 33f49a78a..863cf2c17 100644 --- a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -33,5 +33,6 @@ public interface BindableConversationItem extends Unbindable { void onShowFullClicked(DcMsg messageRecord); void onDownloadClicked(DcMsg messageRecord); void onReactionClicked(DcMsg messageRecord); + void onStickerClicked(DcMsg messageRecord); } } diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 22c9268f1..5bdbf98bd 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -1442,6 +1442,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity isEditing = false; } + @Override + public void onStickerPicked(Uri stickerUri) { + if (isEditing) return; + sendSticker(stickerUri, "image/webp"); + } + + public void refreshStickerPicker() { + if (emojiPicker != null) { + emojiPicker.refreshStickerPicker(); + } + } + // media selected by the system keyboard @Override public void onMediaSelected(@NonNull Uri uri, String contentType) { diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index e1ab38daf..45be8af61 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -38,6 +38,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.lifecycle.ViewModelProvider; @@ -69,6 +70,7 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.ConversationAdaptiveActionsToolbar; +import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -535,6 +537,51 @@ public class ConversationFragment extends MessageSelectorFragment } } + private void handleSaveSticker(final DcMsg message) { + if (message.getType() != DcMsg.DC_MSG_STICKER) { + return; + } + + File stickerFile = message.getFileAsFile(); + if (stickerFile == null || !stickerFile.exists()) { + Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT).show(); + return; + } + + // Create stickers directory in internal storage + File stickersDir = new File(getContext().getFilesDir(), "stickers"); + if (!stickersDir.exists()) { + stickersDir.mkdirs(); + } + + // Copy sticker to stickers directory + String extension = stickerFile.getName(); + int dotPos = extension.lastIndexOf('.'); + if (dotPos >= 0) { + extension = extension.substring(dotPos + 1); + } + String fileName = System.currentTimeMillis() + "." + extension; + File destFile = new File(stickersDir, fileName); + + try (java.io.FileInputStream in = new java.io.FileInputStream(stickerFile); + java.io.FileOutputStream out = new java.io.FileOutputStream(destFile)) { + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + Toast.makeText(getContext(), R.string.saved, Toast.LENGTH_SHORT).show(); + + // Refresh sticker picker to show the newly added sticker + if (getActivity() instanceof ConversationActivity) { + ((ConversationActivity) getActivity()).refreshStickerPicker(); + } + } catch (Exception e) { + Log.e(TAG, "Error saving sticker", e); + Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT).show(); + } + } + @SuppressLint("RestrictedApi") private void handleReplyMessage(final DcMsg message) { if (getActivity() != null) { @@ -925,6 +972,18 @@ public class ConversationFragment extends MessageSelectorFragment ReactionsDetailsFragment dialog = ReactionsDetailsFragment.newInstance(messageRecord.getId()); dialog.show(getActivity().getSupportFragmentManager(), null); } + + @Override + public void onStickerClicked(DcMsg messageRecord) { + new AlertDialog.Builder(getContext()) + .setTitle(R.string.add_to_sticker_collection) + .setMessage(R.string.ask_add_sticker_to_collection) + .setPositiveButton(R.string.ok, (dialog, which) -> { + handleSaveSticker(messageRecord); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 6d089d55f..6316afb78 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -965,6 +965,8 @@ public class ConversationItem extends BaseConversationItem public void onClick(final View v, final Slide slide) { if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { performClick(); + } else if (eventListener != null) { + eventListener.onStickerClicked(messageRecord); } } } diff --git a/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index 16cfb012d..509636873 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -326,6 +326,13 @@ public class InputPanel extends ConstraintLayout composeText.setSelection(start + emoji.length()); } + @Override + public void onStickerPicked(Uri stickerUri) { + if (listener != null) { + listener.onStickerPicked(stickerUri); + } + } + public interface Listener { void onRecorderStarted(); void onRecorderLocked(); @@ -334,6 +341,7 @@ public class InputPanel extends ConstraintLayout void onRecorderPermissionRequired(); void onEmojiToggle(); void onQuoteDismissed(); + void onStickerPicked(Uri stickerUri); } private static class SlideToCancel { diff --git a/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java index 6bc3a8fd7..91d022cc9 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java +++ b/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.components.emoji; import android.content.Context; +import android.graphics.drawable.Drawable; +import android.net.Uri; import android.util.AttributeSet; import android.util.Log; +import android.view.View; import android.view.ViewGroup; -import android.widget.FrameLayout; +import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,15 +15,23 @@ import androidx.core.util.Consumer; import androidx.emoji2.emojipicker.EmojiPickerView; import androidx.emoji2.emojipicker.EmojiViewItem; +import com.google.android.material.tabs.TabLayout; + import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.InputAwareLayout.InputView; +import org.thoughtcrime.securesms.util.ResUtil; -public class MediaKeyboard extends FrameLayout implements InputView, Consumer { +import java.io.File; + +public class MediaKeyboard extends LinearLayout implements InputView, Consumer, StickerPickerView.StickerPickerListener { private static final String TAG = MediaKeyboard.class.getSimpleName(); - @Nullable private MediaKeyboardListener keyboardListener; + @Nullable private MediaKeyboardListener keyboardListener; private EmojiPickerView emojiPicker; + private StickerPickerView stickerPicker; + private View stickerPickerContainer; + private View stickerPickerEmpty; public MediaKeyboard(@NonNull Context context) { super(context); @@ -51,13 +62,90 @@ public class MediaKeyboard extends FrameLayout implements InputView, Consumer 0; + stickerPickerEmpty.setVisibility(hasStickers ? View.GONE : View.VISIBLE); + } + } + + public void refreshStickerPicker() { + if (stickerPicker != null) { + stickerPicker.loadStickers(); + updateStickerEmptyState(); + } + } + @Override public void hide(boolean immediate) { setVisibility(GONE); @@ -70,9 +158,22 @@ public class MediaKeyboard extends FrameLayout implements InputView, Consumer stickerFiles = getSavedStickers(); + setAdapter(new StickerAdapter(getContext(), glideRequests, stickerFiles, listener)); + } + + private List getSavedStickers() { + List stickerFiles = new ArrayList<>(); + + if (!stickerDir.exists()) { + return stickerFiles; + } + + File[] files = stickerDir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile()) { + stickerFiles.add(file); + } + } + } + + return stickerFiles; + } + + public interface StickerPickerListener { + void onStickerSelected(@NonNull File stickerFile); + void onStickerDeleted(@NonNull File stickerFile); + } + + static class StickerAdapter extends RecyclerView.Adapter { + + private final Context context; + private final GlideRequests glideRequests; + private final List stickerFiles; + private final LayoutInflater layoutInflater; + private final StickerPickerListener listener; + + StickerAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @NonNull List stickerFiles, + @Nullable StickerPickerListener listener) { + this.context = context; + this.glideRequests = glideRequests; + this.stickerFiles = stickerFiles; + this.layoutInflater = LayoutInflater.from(context); + this.listener = listener; + } + + @Override + public @NonNull StickerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = layoutInflater.inflate(R.layout.sticker_picker_item, parent, false); + return new StickerViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull StickerViewHolder holder, int position) { + File stickerFile = stickerFiles.get(position); + holder.stickerFile = stickerFile; + + glideRequests.load(stickerFile) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(holder.image); + } + + @Override + public int getItemCount() { + return stickerFiles.size(); + } + + @Override + public void onViewRecycled(@NonNull StickerViewHolder holder) { + super.onViewRecycled(holder); + glideRequests.clear(holder.image); + } + + private void deleteSticker(File stickerFile) { + if (stickerFile != null && stickerFile.exists()) { + new androidx.appcompat.app.AlertDialog.Builder(context) + .setTitle(R.string.delete) + .setMessage(R.string.ask_delete_sticker) + .setPositiveButton(R.string.delete, (dialog, which) -> { + if (stickerFile.delete()) { + int position = stickerFiles.indexOf(stickerFile); + if (position >= 0) { + stickerFiles.remove(position); + notifyItemRemoved(position); + } + if (listener != null) { + listener.onStickerDeleted(stickerFile); + } + } + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + class StickerViewHolder extends RecyclerView.ViewHolder { + + private File stickerFile; + private final ImageView image; + + StickerViewHolder(View itemView) { + super(itemView); + image = itemView.findViewById(R.id.sticker_image); + itemView.setOnClickListener(view -> { + if (listener != null && stickerFile != null) { + listener.onStickerSelected(stickerFile); + } + }); + itemView.setOnLongClickListener(view -> { + if (stickerFile != null) { + deleteSticker(stickerFile); + return true; + } + return false; + }); + } + } + } +} diff --git a/src/main/res/drawable/ic_sticker_24.xml b/src/main/res/drawable/ic_sticker_24.xml new file mode 100644 index 000000000..102dedfd5 --- /dev/null +++ b/src/main/res/drawable/ic_sticker_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml b/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml deleted file mode 100644 index 9417360bf..000000000 --- a/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/src/main/res/layout/conversation_activity_emojidrawer_stub.xml b/src/main/res/layout/conversation_activity_emojidrawer_stub.xml index 742adefaf..02514ac2c 100644 --- a/src/main/res/layout/conversation_activity_emojidrawer_stub.xml +++ b/src/main/res/layout/conversation_activity_emojidrawer_stub.xml @@ -1,19 +1,69 @@ - - + + + + + + + + + + + + + + + app:tabMode="fixed" + app:tabGravity="center" + app:tabIndicatorGravity="top" + app:tabIconTint="?attr/colorControlNormal" + app:tabPaddingStart="16dp" + app:tabPaddingEnd="16dp" + app:tabPaddingTop="10dp" + app:tabPaddingBottom="10dp" + /> diff --git a/src/main/res/layout/sticker_picker_item.xml b/src/main/res/layout/sticker_picker_item.xml new file mode 100644 index 000000000..6f02a0d19 --- /dev/null +++ b/src/main/res/layout/sticker_picker_item.xml @@ -0,0 +1,7 @@ + + diff --git a/src/main/res/values-land/integers.xml b/src/main/res/values-land/integers.xml new file mode 100644 index 000000000..b3a0b3815 --- /dev/null +++ b/src/main/res/values-land/integers.xml @@ -0,0 +1,4 @@ + + + 12 + diff --git a/src/main/res/values/integers.xml b/src/main/res/values/integers.xml index bb4c483c9..d485069c5 100644 --- a/src/main/res/values/integers.xml +++ b/src/main/res/values/integers.xml @@ -1,4 +1,5 @@ 300 - \ No newline at end of file + 9 + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 24cc287ff..5e3365964 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -178,6 +178,9 @@ Add to Sticker Collection To add stickers, tap "Open Sticker Folder", create a subfolder for your sticker pack, and drag image and sticker files there Open Sticker Folder + Add this sticker to your collection? + Delete this sticker? + Saved stickers will appear here. Images Audio Voice Message