mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Add Emoji Search, Sticker Search, and GIF Keyboard.
Co-authored-by: Alex Hart <alex@signal.org> Co-authored-by: Cody Henthorne <cody@signal.org> Co-authored-by: Greyson Parrelli<greyson@signal.org>
This commit is contained in:
committed by
Cody Henthorne
parent
66c3b1388a
commit
08e86b8c82
@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
@@ -156,10 +157,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
|
||||
.addNonBlocking(EmojiSource::refresh)
|
||||
.addNonBlocking(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
@@ -26,8 +26,8 @@ public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKey
|
||||
addOnKeyboardShownListener(this);
|
||||
}
|
||||
|
||||
@Override public void onKeyboardShown() {
|
||||
hideAttachedInput(true);
|
||||
@Override
|
||||
public void onKeyboardShown() {
|
||||
}
|
||||
|
||||
public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
@@ -279,8 +280,8 @@ public class InputPanel extends LinearLayout
|
||||
mediaKeyboard.setVisibility(show ? View.VISIBLE : GONE);
|
||||
}
|
||||
|
||||
public void setMediaKeyboardToggleMode(boolean isSticker) {
|
||||
mediaKeyboard.setStickerMode(isSticker);
|
||||
public void setMediaKeyboardToggleMode(@NonNull KeyboardPage page) {
|
||||
mediaKeyboard.setStickerMode(page);
|
||||
}
|
||||
|
||||
public boolean isStickerMode() {
|
||||
@@ -291,6 +292,10 @@ public class InputPanel extends LinearLayout
|
||||
return mediaKeyboard;
|
||||
}
|
||||
|
||||
public MediaKeyboard.MediaKeyboardListener getMediaKeyboardListener() {
|
||||
return mediaKeyboard;
|
||||
}
|
||||
|
||||
public void setWallpaperEnabled(boolean enabled) {
|
||||
if (enabled) {
|
||||
setBackground(new ColorDrawable(getContext().getResources().getColor(R.color.wallpaper_compose_background)));
|
||||
|
||||
@@ -23,6 +23,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}.
|
||||
*
|
||||
* TODO [alex] -- Are we still using any of this?
|
||||
*/
|
||||
public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
MediaKeyboardProvider.TabIconProvider,
|
||||
@@ -31,6 +33,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
{
|
||||
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
|
||||
|
||||
// TODO [alex] -- We are using this.
|
||||
public static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
|
||||
|
||||
private final Context context;
|
||||
@@ -146,7 +149,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
|
||||
@Override
|
||||
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true);
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true, null);
|
||||
page.setModel(pages.get(position));
|
||||
container.addView(page);
|
||||
return page;
|
||||
|
||||
@@ -6,58 +6,111 @@ import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
|
||||
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
|
||||
private static final String TAG = Log.tag(EmojiPageView.class);
|
||||
|
||||
private EmojiPageModel model;
|
||||
private EmojiPageViewGridAdapter adapter;
|
||||
private AdapterFactory adapterFactory;
|
||||
private RecyclerView recyclerView;
|
||||
private GridLayoutManager layoutManager;
|
||||
private RecyclerView.LayoutManager layoutManager;
|
||||
private RecyclerView.OnItemTouchListener scrollDisabler;
|
||||
private VariationSelectorListener variationSelectorListener;
|
||||
private EmojiVariationSelectorPopup popup;
|
||||
private boolean searchEnabled;
|
||||
private SpanSizeLookup spanSizeLookup;
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
boolean allowVariations,
|
||||
@Nullable KeyboardPageSearchView.Callbacks searchCallbacks)
|
||||
{
|
||||
this(context, emojiSelectionListener, variationSelectorListener, allowVariations, searchCallbacks, new GridLayoutManager(context, 8), R.layout.emoji_display_item);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@Nullable KeyboardPageSearchView.Callbacks searchCallbacks,
|
||||
@NonNull RecyclerView.LayoutManager layoutManager,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
{
|
||||
super(context);
|
||||
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
|
||||
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
recyclerView = view.findViewById(R.id.emoji);
|
||||
layoutManager = new GridLayoutManager(context, 8);
|
||||
scrollDisabler = new ScrollDisabler();
|
||||
popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
|
||||
adapter = new EmojiPageViewGridAdapter(popup,
|
||||
emojiSelectionListener,
|
||||
this,
|
||||
allowVariations);
|
||||
this.recyclerView = view.findViewById(R.id.emoji);
|
||||
this.layoutManager = layoutManager;
|
||||
this.scrollDisabler = new ScrollDisabler();
|
||||
this.popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
|
||||
this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
|
||||
emojiSelectionListener,
|
||||
this,
|
||||
allowVariations,
|
||||
displayItemLayoutResId,
|
||||
searchCallbacks);
|
||||
|
||||
if (layoutManager instanceof GridLayoutManager) {
|
||||
spanSizeLookup = new SpanSizeLookup();
|
||||
((GridLayoutManager) layoutManager).setSpanSizeLookup(spanSizeLookup);
|
||||
}
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setAdapter(adapter);
|
||||
recyclerView.setItemAnimator(null);
|
||||
}
|
||||
|
||||
public void onSelected() {
|
||||
if (model.isDynamic() && adapter != null) {
|
||||
adapter.notifyDataSetChanged();
|
||||
if (model.isDynamic() && recyclerView.getAdapter() != null) {
|
||||
recyclerView.getAdapter().notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void setModel(EmojiPageModel model) {
|
||||
public void setModel(@Nullable EmojiPageModel model) {
|
||||
this.model = model;
|
||||
adapter.setEmoji(model.getDisplayEmoji());
|
||||
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
recyclerView.setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList());
|
||||
}
|
||||
|
||||
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
|
||||
this.searchEnabled = true;
|
||||
this.model = model;
|
||||
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
recyclerView.setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList(), () -> layoutManager.scrollToPosition(1));
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList getMappingModelList() {
|
||||
MappingModelList mappingModels = new MappingModelList();
|
||||
|
||||
if (searchEnabled) {
|
||||
mappingModels.add(new EmojiPageViewGridAdapter.SearchModel());
|
||||
}
|
||||
|
||||
if (model != null) {
|
||||
mappingModels.addAll(Stream.of(model.getDisplayEmoji()).map(EmojiPageViewGridAdapter.EmojiModel::new).toList());
|
||||
}
|
||||
|
||||
return mappingModels;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -69,8 +122,13 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
layoutManager.setSpanCount(Math.max(w / idealWidth, 1));
|
||||
if (layoutManager instanceof GridLayoutManager) {
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
int spanCount = Math.max(w / idealWidth, 1);
|
||||
|
||||
spanSizeLookup.setSpansPerRow(spanCount);
|
||||
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -102,4 +160,22 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
@Override
|
||||
public void onRequestDisallowInterceptTouchEvent(boolean b) { }
|
||||
}
|
||||
|
||||
private class SpanSizeLookup extends GridLayoutManager.SpanSizeLookup {
|
||||
|
||||
private int spansPerRow;
|
||||
|
||||
public void setSpansPerRow(int spansPerRow) {
|
||||
this.spansPerRow = spansPerRow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSpanSize(int position) {
|
||||
return position == 0 && searchEnabled ? spansPerRow : 1;
|
||||
}
|
||||
}
|
||||
|
||||
private interface AdapterFactory {
|
||||
EmojiPageViewGridAdapter create();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,42 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupWindow;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
|
||||
|
||||
public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageViewGridAdapter.EmojiViewHolder> implements PopupWindow.OnDismissListener {
|
||||
|
||||
private final List<Emoji> emojiList;
|
||||
private final EmojiVariationSelectorPopup popup;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final boolean allowVariations;
|
||||
|
||||
public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
boolean allowVariations,
|
||||
@LayoutRes int displayItemLayoutResId,
|
||||
@Nullable KeyboardPageSearchView.Callbacks callbacks)
|
||||
{
|
||||
this.emojiList = new ArrayList<>();
|
||||
this.popup = popup;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
popup.setOnDismissListener(this);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull EmojiViewHolder viewHolder, int i) {
|
||||
Emoji emoji = emojiList.get(i);
|
||||
|
||||
final Drawable drawable = EmojiProvider.getEmojiDrawable(viewHolder.imageView.getContext(), emoji.getValue());
|
||||
|
||||
if (drawable != null) {
|
||||
viewHolder.textView.setVisibility(View.GONE);
|
||||
viewHolder.imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
viewHolder.imageView.setImageDrawable(drawable);
|
||||
} else {
|
||||
viewHolder.textView.setVisibility(View.VISIBLE);
|
||||
viewHolder.imageView.setVisibility(View.GONE);
|
||||
|
||||
viewHolder.textView.setEmoji(emoji.getValue());
|
||||
}
|
||||
|
||||
viewHolder.itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(emoji.getValue());
|
||||
});
|
||||
|
||||
if (allowVariations && emoji.getVariations().size() > 1) {
|
||||
viewHolder.itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(emoji.getVariations());
|
||||
popup.showAsDropDown(viewHolder.itemView, 0, -(2 * viewHolder.itemView.getHeight()));
|
||||
variationSelectorListener.onVariationSelectorStateChanged(true);
|
||||
return true;
|
||||
});
|
||||
viewHolder.hintCorner.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
viewHolder.itemView.setOnLongClickListener(null);
|
||||
viewHolder.hintCorner.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return emojiList.size();
|
||||
}
|
||||
|
||||
public void setEmoji(@NonNull List<Emoji> emojiList) {
|
||||
this.emojiList.clear();
|
||||
this.emojiList.addAll(emojiList);
|
||||
notifyDataSetChanged();
|
||||
registerFactory(SearchModel.class, new LayoutFactory<>(v -> {
|
||||
((KeyboardPageSearchView) v).setCallbacks(callbacks);
|
||||
return new SearchViewHolder(v);
|
||||
}, R.layout.emoji_page_view_search));
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayItemLayoutResId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,18 +44,110 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
|
||||
variationSelectorListener.onVariationSelectorStateChanged(false);
|
||||
}
|
||||
|
||||
static class EmojiViewHolder extends RecyclerView.ViewHolder {
|
||||
static class SearchModel implements MappingModel<SearchModel> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull @NotNull SearchModel newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull @NotNull SearchModel newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static class SearchViewHolder extends MappingViewHolder<SearchModel> {
|
||||
public SearchViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull @NotNull SearchModel model) {
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiModel implements MappingModel<EmojiModel> {
|
||||
|
||||
private final Emoji emoji;
|
||||
|
||||
EmojiModel(@NonNull Emoji emoji) {
|
||||
this.emoji = emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull @NotNull EmojiModel newItem) {
|
||||
return newItem.emoji.getValue().equals(emoji.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull @NotNull EmojiModel newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiViewHolder extends MappingViewHolder<EmojiModel> {
|
||||
|
||||
private final EmojiVariationSelectorPopup popup;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final boolean allowVariations;
|
||||
|
||||
private final ImageView imageView;
|
||||
private final AsciiEmojiView textView;
|
||||
private final ImageView hintCorner;
|
||||
|
||||
public EmojiViewHolder(@NonNull View itemView) {
|
||||
public EmojiViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
@NonNull EmojiVariationSelectorPopup popup,
|
||||
boolean allowVariations)
|
||||
{
|
||||
super(itemView);
|
||||
|
||||
this.popup = popup;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
this.imageView = itemView.findViewById(R.id.emoji_image);
|
||||
this.textView = itemView.findViewById(R.id.emoji_text);
|
||||
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull @NotNull EmojiModel model) {
|
||||
final Drawable drawable = EmojiProvider.getEmojiDrawable(imageView.getContext(), model.emoji.getValue());
|
||||
|
||||
if (drawable != null) {
|
||||
textView.setVisibility(View.GONE);
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
imageView.setImageDrawable(drawable);
|
||||
} else {
|
||||
textView.setVisibility(View.VISIBLE);
|
||||
imageView.setVisibility(View.GONE);
|
||||
|
||||
textView.setEmoji(model.emoji.getValue());
|
||||
}
|
||||
|
||||
itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(model.emoji.getValue());
|
||||
});
|
||||
|
||||
if (allowVariations && model.emoji.getVariations().size() > 1) {
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(model.emoji.getVariations());
|
||||
popup.showAsDropDown(itemView, 0, -(2 * itemView.getHeight()));
|
||||
variationSelectorListener.onVariationSelectorStateChanged(true);
|
||||
return true;
|
||||
});
|
||||
hintCorner.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
itemView.setOnLongClickListener(null);
|
||||
hintCorner.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface VariationSelectorListener {
|
||||
|
||||
@@ -9,13 +9,16 @@ import androidx.appcompat.widget.AppCompatImageButton;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener {
|
||||
|
||||
private Drawable emojiToggle;
|
||||
private Drawable stickerToggle;
|
||||
private Drawable gifToggle;
|
||||
|
||||
private Drawable mediaToggle;
|
||||
private Drawable imeToggle;
|
||||
@@ -45,9 +48,10 @@ public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.M
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.emojiToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_emoji_smiley_24);
|
||||
this.stickerToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_sticker_24);
|
||||
this.imeToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_keyboard_24);
|
||||
this.emojiToggle = ContextUtil.requireDrawable(getContext(), R.drawable.keyboard_pager_fragment_emoji_icon);
|
||||
this.stickerToggle = ContextUtil.requireDrawable(getContext(), R.drawable.keyboard_pager_fragment_sticker_icon);
|
||||
this.gifToggle = ContextUtil.requireDrawable(getContext(), R.drawable.keyboard_pager_fragment_gif_icon);
|
||||
this.imeToggle = ContextUtil.requireDrawable(getContext(), R.drawable.ic_keyboard_24);
|
||||
this.mediaToggle = emojiToggle;
|
||||
|
||||
setToMedia();
|
||||
@@ -57,8 +61,18 @@ public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.M
|
||||
drawer.setKeyboardListener(this);
|
||||
}
|
||||
|
||||
public void setStickerMode(boolean stickerMode) {
|
||||
this.mediaToggle = stickerMode ? stickerToggle : emojiToggle;
|
||||
public void setStickerMode(@NonNull KeyboardPage page) {
|
||||
switch (page) {
|
||||
case EMOJI:
|
||||
mediaToggle = emojiToggle;
|
||||
break;
|
||||
case STICKER:
|
||||
mediaToggle = stickerToggle;
|
||||
break;
|
||||
case GIF:
|
||||
mediaToggle = gifToggle;
|
||||
break;
|
||||
}
|
||||
|
||||
if (getDrawable() != imeToggle) {
|
||||
setToMedia();
|
||||
@@ -78,9 +92,18 @@ public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.M
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) {
|
||||
setStickerMode(provider instanceof StickerKeyboardProvider);
|
||||
TextSecurePreferences.setMediaKeyboardMode(getContext(), (provider instanceof StickerKeyboardProvider) ? TextSecurePreferences.MediaKeyboardMode.STICKER
|
||||
: TextSecurePreferences.MediaKeyboardMode.EMOJI);
|
||||
public void onKeyboardChanged(@NonNull KeyboardPage page) {
|
||||
setStickerMode(page);
|
||||
switch (page) {
|
||||
case EMOJI:
|
||||
TextSecurePreferences.setMediaKeyboardMode(getContext(), TextSecurePreferences.MediaKeyboardMode.EMOJI);
|
||||
break;
|
||||
case STICKER:
|
||||
TextSecurePreferences.setMediaKeyboardMode(getContext(), TextSecurePreferences.MediaKeyboardMode.STICKER);
|
||||
break;
|
||||
case GIF:
|
||||
TextSecurePreferences.setMediaKeyboardMode(getContext(), TextSecurePreferences.MediaKeyboardMode.GIF);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -10,18 +9,20 @@ import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
|
||||
import org.thoughtcrime.securesms.components.RepeatableImageKey;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.security.Key;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
MediaKeyboardProvider.Presenter,
|
||||
@@ -29,22 +30,15 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
MediaKeyboardBottomTabAdapter.EventListener
|
||||
{
|
||||
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
private static final String EMOJI_SEARCH = "emoji_search_fragment";
|
||||
|
||||
private RecyclerView categoryTabs;
|
||||
private ViewPager categoryPager;
|
||||
private ViewGroup providerTabs;
|
||||
private RepeatableImageKey backspaceButton;
|
||||
private RepeatableImageKey backspaceButtonBackup;
|
||||
private View searchButton;
|
||||
private View addButton;
|
||||
@Nullable private MediaKeyboardListener keyboardListener;
|
||||
private MediaKeyboardProvider[] providers;
|
||||
private int providerIndex;
|
||||
|
||||
private final boolean tabsAtBottom;
|
||||
|
||||
private MediaKeyboardBottomTabAdapter categoryTabAdapter;
|
||||
@Nullable private MediaKeyboardListener keyboardListener;
|
||||
private boolean isInitialised;
|
||||
private int latestKeyboardHeight;
|
||||
private State keyboardState;
|
||||
private KeyboardPagerFragment keyboardPagerFragment;
|
||||
private FragmentManager fragmentManager;
|
||||
|
||||
public MediaKeyboard(Context context) {
|
||||
this(context, null);
|
||||
@@ -52,23 +46,6 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
|
||||
public MediaKeyboard(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MediaKeyboard, 0, 0);
|
||||
|
||||
try {
|
||||
tabsAtBottom = typedArray.getInt(R.styleable.MediaKeyboard_tabs_gravity, 0) == 0;
|
||||
} finally {
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void setProviders(int startIndex, MediaKeyboardProvider... providers) {
|
||||
if (!Arrays.equals(this.providers, providers)) {
|
||||
this.providers = providers;
|
||||
this.providerIndex = startIndex;
|
||||
|
||||
requestPresent(providers, providerIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
|
||||
@@ -82,10 +59,12 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
|
||||
@Override
|
||||
public void show(int height, boolean immediate) {
|
||||
if (this.categoryPager == null) initView();
|
||||
if (!isInitialised) initView();
|
||||
|
||||
latestKeyboardHeight = height;
|
||||
|
||||
ViewGroup.LayoutParams params = getLayoutParams();
|
||||
params.height = height;
|
||||
params.height = (keyboardState == State.NORMAL) ? latestKeyboardHeight : ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
Log.i(TAG, "showing emoji drawer with height " + params.height);
|
||||
setLayoutParams(params);
|
||||
|
||||
@@ -93,19 +72,20 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
}
|
||||
|
||||
public void show() {
|
||||
if (this.categoryPager == null) initView();
|
||||
if (!isInitialised) initView();
|
||||
|
||||
setVisibility(VISIBLE);
|
||||
if (keyboardListener != null) keyboardListener.onShown();
|
||||
|
||||
requestPresent(providers, providerIndex);
|
||||
keyboardPagerFragment.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hide(boolean immediate) {
|
||||
setVisibility(GONE);
|
||||
onCloseEmojiSearchInternal(false);
|
||||
if (keyboardListener != null) keyboardListener.onHidden();
|
||||
Log.i(TAG, "hide()");
|
||||
keyboardPagerFragment.hide();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,30 +97,29 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
@Nullable MediaKeyboardProvider.SearchObserver searchObserver,
|
||||
int startingIndex)
|
||||
{
|
||||
if (categoryPager == null) return;
|
||||
if (!provider.equals(providers[providerIndex])) return;
|
||||
if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(provider);
|
||||
|
||||
boolean isSolo = providers.length == 1;
|
||||
|
||||
presentProviderStrip(isSolo);
|
||||
presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
|
||||
presentProviderTabs(providers, providerIndex);
|
||||
presentSearchButton(searchObserver);
|
||||
presentBackspaceButton(backspaceObserver, isSolo);
|
||||
presentAddButton(addObserver);
|
||||
// if (categoryPager == null) return;
|
||||
// if (!provider.equals(providers[providerIndex])) return;
|
||||
// if (keyboardListener != null) keyboardListener.onKeyboardChanged(provider);
|
||||
//
|
||||
// boolean isSolo = providers.length == 1;
|
||||
//
|
||||
// presentProviderStrip(isSolo);
|
||||
// presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
|
||||
// presentProviderTabs(providers, providerIndex);
|
||||
// presentSearchButton(searchObserver);
|
||||
// presentBackspaceButton(backspaceObserver, isSolo);
|
||||
// presentAddButton(addObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentPosition() {
|
||||
return categoryPager != null ? categoryPager.getCurrentItem() : 0;
|
||||
// return categoryPager != null ? categoryPager.getCurrentItem() : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestDismissal() {
|
||||
hide(true);
|
||||
providerIndex = 0;
|
||||
if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(providers[providerIndex]);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -150,148 +129,82 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
|
||||
@Override
|
||||
public void onTabSelected(int index) {
|
||||
if (categoryPager != null) {
|
||||
categoryPager.setCurrentItem(index);
|
||||
categoryTabs.smoothScrollToPosition(index);
|
||||
}
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setCurrentItem(index);
|
||||
// categoryTabs.smoothScrollToPosition(index);
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setViewPagerEnabled(boolean enabled) {
|
||||
if (categoryPager != null) {
|
||||
categoryPager.setEnabled(enabled);
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setEnabled(enabled);
|
||||
// }
|
||||
}
|
||||
|
||||
public void onCloseEmojiSearch() {
|
||||
onCloseEmojiSearchInternal(true);
|
||||
}
|
||||
|
||||
private void onCloseEmojiSearchInternal(boolean showAfterCommit) {
|
||||
if (keyboardState == State.NORMAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardState = State.NORMAL;
|
||||
|
||||
Fragment emojiSearch = fragmentManager.findFragmentByTag(EMOJI_SEARCH);
|
||||
if (emojiSearch == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FragmentTransaction transaction = fragmentManager.beginTransaction()
|
||||
.remove(emojiSearch)
|
||||
.show(keyboardPagerFragment)
|
||||
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out);
|
||||
|
||||
if (showAfterCommit) {
|
||||
transaction.runOnCommit(() -> show(latestKeyboardHeight, false));
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
public void onOpenEmojiSearch() {
|
||||
if (keyboardState == State.EMOJI_SEARCH) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardState = State.EMOJI_SEARCH;
|
||||
|
||||
fragmentManager.beginTransaction()
|
||||
.hide(keyboardPagerFragment)
|
||||
.add(R.id.media_keyboard_fragment_container, new EmojiSearchFragment(), EMOJI_SEARCH)
|
||||
.runOnCommit(() -> show(latestKeyboardHeight, true))
|
||||
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out)
|
||||
.commit();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
|
||||
if (!isInitialised) {
|
||||
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
|
||||
|
||||
RecyclerView categoryTabsTop = view.findViewById(R.id.media_keyboard_tabs_top);
|
||||
RecyclerView categoryTabsBottom = view.findViewById(R.id.media_keyboard_tabs);
|
||||
|
||||
this.categoryTabs = tabsAtBottom ? categoryTabsBottom : categoryTabsTop;
|
||||
this.categoryPager = view.findViewById(R.id.media_keyboard_pager);
|
||||
this.providerTabs = view.findViewById(R.id.media_keyboard_provider_tabs);
|
||||
this.backspaceButton = view.findViewById(R.id.media_keyboard_backspace);
|
||||
this.backspaceButtonBackup = view.findViewById(R.id.media_keyboard_backspace_backup);
|
||||
this.searchButton = view.findViewById(R.id.media_keyboard_search);
|
||||
this.addButton = view.findViewById(R.id.media_keyboard_add);
|
||||
|
||||
this.categoryTabAdapter = new MediaKeyboardBottomTabAdapter(GlideApp.with(this), this, tabsAtBottom);
|
||||
|
||||
categoryTabs.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
|
||||
categoryTabs.setAdapter(categoryTabAdapter);
|
||||
categoryTabs.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
private void requestPresent(@NonNull MediaKeyboardProvider[] providers, int newIndex) {
|
||||
providers[providerIndex].setController(null);
|
||||
providerIndex = newIndex;
|
||||
|
||||
providers[providerIndex].setController(this);
|
||||
providers[providerIndex].requestPresentation(this, providers.length == 1);
|
||||
}
|
||||
|
||||
|
||||
private void presentCategoryPager(@NonNull PagerAdapter pagerAdapter,
|
||||
@NonNull MediaKeyboardProvider.TabIconProvider iconProvider,
|
||||
int startingIndex) {
|
||||
if (categoryPager.getAdapter() != pagerAdapter) {
|
||||
categoryPager.setAdapter(pagerAdapter);
|
||||
keyboardState = State.NORMAL;
|
||||
latestKeyboardHeight = -1;
|
||||
isInitialised = true;
|
||||
fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
|
||||
keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
|
||||
}
|
||||
|
||||
categoryPager.setCurrentItem(startingIndex);
|
||||
|
||||
categoryPager.clearOnPageChangeListeners();
|
||||
categoryPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
|
||||
@Override
|
||||
public void onPageScrolled(int i, float v, int i1) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageSelected(int i) {
|
||||
categoryTabAdapter.setActivePosition(i);
|
||||
categoryTabs.smoothScrollToPosition(i);
|
||||
providers[providerIndex].setCurrentPosition(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageScrollStateChanged(int i) {
|
||||
}
|
||||
});
|
||||
|
||||
categoryTabAdapter.setTabIconProvider(iconProvider, pagerAdapter.getCount());
|
||||
categoryTabAdapter.setActivePosition(startingIndex);
|
||||
}
|
||||
|
||||
private void presentProviderTabs(@NonNull MediaKeyboardProvider[] providers, int selected) {
|
||||
providerTabs.removeAllViews();
|
||||
|
||||
LayoutInflater inflater = LayoutInflater.from(getContext());
|
||||
|
||||
for (int i = 0; i < providers.length; i++) {
|
||||
MediaKeyboardProvider provider = providers[i];
|
||||
View view = inflater.inflate(provider.getProviderIconView(i == selected), providerTabs, false);
|
||||
|
||||
view.setTag(provider);
|
||||
|
||||
final int index = i;
|
||||
view.setOnClickListener(v -> {
|
||||
requestPresent(providers, index);
|
||||
});
|
||||
|
||||
providerTabs.addView(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentBackspaceButton(@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
|
||||
boolean useBackupPosition)
|
||||
{
|
||||
if (backspaceObserver != null) {
|
||||
if (useBackupPosition) {
|
||||
backspaceButton.setVisibility(INVISIBLE);
|
||||
backspaceButton.setOnKeyEventListener(null);
|
||||
backspaceButtonBackup.setVisibility(VISIBLE);
|
||||
backspaceButtonBackup.setOnKeyEventListener(backspaceObserver::onBackspaceClicked);
|
||||
} else {
|
||||
backspaceButton.setVisibility(VISIBLE);
|
||||
backspaceButton.setOnKeyEventListener(backspaceObserver::onBackspaceClicked);
|
||||
backspaceButtonBackup.setVisibility(GONE);
|
||||
backspaceButtonBackup.setOnKeyEventListener(null);
|
||||
}
|
||||
} else {
|
||||
backspaceButton.setVisibility(INVISIBLE);
|
||||
backspaceButton.setOnKeyEventListener(null);
|
||||
backspaceButtonBackup.setVisibility(GONE);
|
||||
backspaceButton.setOnKeyEventListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentAddButton(@Nullable MediaKeyboardProvider.AddObserver addObserver) {
|
||||
if (addObserver != null) {
|
||||
addButton.setVisibility(VISIBLE);
|
||||
addButton.setOnClickListener(v -> addObserver.onAddClicked());
|
||||
} else {
|
||||
addButton.setVisibility(GONE);
|
||||
addButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentSearchButton(@Nullable MediaKeyboardProvider.SearchObserver searchObserver) {
|
||||
searchButton.setVisibility(searchObserver != null ? VISIBLE : INVISIBLE);
|
||||
}
|
||||
|
||||
private void presentProviderStrip(boolean isSolo) {
|
||||
int visibility = isSolo ? View.GONE : View.VISIBLE;
|
||||
|
||||
searchButton.setVisibility(visibility);
|
||||
backspaceButton.setVisibility(visibility);
|
||||
providerTabs.setVisibility(visibility);
|
||||
}
|
||||
|
||||
public interface MediaKeyboardListener {
|
||||
void onShown();
|
||||
void onHidden();
|
||||
void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider);
|
||||
void onKeyboardChanged(@NonNull KeyboardPage page);
|
||||
}
|
||||
|
||||
private enum State {
|
||||
NORMAL,
|
||||
EMOJI_SEARCH
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,22 +16,19 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKey
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
private final boolean highlightTop;
|
||||
|
||||
private TabIconProvider tabIconProvider;
|
||||
private int activePosition;
|
||||
private int count;
|
||||
|
||||
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean highlightTop) {
|
||||
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
this.highlightTop = highlightTop;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false),
|
||||
highlightTop);
|
||||
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,18 +61,12 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKey
|
||||
static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ImageView image;
|
||||
private final View indicator;
|
||||
private final View imageSelected;
|
||||
|
||||
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView, boolean highlightTop) {
|
||||
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
|
||||
View indicatorTop = itemView.findViewById(R.id.media_keyboard_top_tab_indicator);
|
||||
View indicatorBottom = itemView.findViewById(R.id.media_keyboard_bottom_tab_indicator);
|
||||
|
||||
this.image = itemView.findViewById(R.id.media_keyboard_bottom_tab_image);
|
||||
this.indicator = highlightTop ? indicatorTop : indicatorBottom;
|
||||
|
||||
this.indicator.setVisibility(View.VISIBLE);
|
||||
this.image = itemView.findViewById(R.id.category_icon);
|
||||
this.imageSelected = itemView.findViewById(R.id.category_icon_selected);
|
||||
}
|
||||
|
||||
void bind(@NonNull GlideRequests glideRequests,
|
||||
@@ -86,9 +77,7 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKey
|
||||
{
|
||||
tabIconProvider.loadCategoryTabIcon(glideRequests, image, index);
|
||||
image.setAlpha(selected ? 1 : 0.5f);
|
||||
image.setSelected(selected);
|
||||
|
||||
indicator.setVisibility(selected ? View.VISIBLE : View.INVISIBLE);
|
||||
imageSelected.setSelected(selected);
|
||||
|
||||
itemView.setOnClickListener(v -> eventListener.onTabSelected(index));
|
||||
}
|
||||
@@ -98,7 +87,7 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKey
|
||||
}
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
public interface EventListener {
|
||||
void onTabSelected(int index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
private final String preferenceName;
|
||||
private final LinkedHashSet<String> recentlyUsed;
|
||||
|
||||
public static boolean hasRecents(Context context, @NonNull String preferenceName) {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).contains(preferenceName);
|
||||
}
|
||||
|
||||
public RecentEmojiPageModel(Context context, @NonNull String preferenceName) {
|
||||
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
this.preferenceName = preferenceName;
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.appearance
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
@@ -28,6 +29,7 @@ class AppearanceSettingsViewModel : ViewModel() {
|
||||
fun setLanguage(language: String) {
|
||||
store.update { it.copy(language = language) }
|
||||
SignalStore.settings().language = language
|
||||
EmojiSearchIndexDownloadJob.scheduleImmediately()
|
||||
}
|
||||
|
||||
fun setMessageFontSize(size: Int) {
|
||||
|
||||
@@ -184,6 +184,11 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
@@ -319,7 +324,12 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
ConversationReactionOverlay.OnReactionSelectedListener,
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
||||
SafetyNumberChangeDialog.Callback,
|
||||
ReactionsBottomSheetDialogFragment.Callback
|
||||
ReactionsBottomSheetDialogFragment.Callback,
|
||||
MediaKeyboard.MediaKeyboardListener,
|
||||
EmojiKeyboardProvider.EmojiEventListener,
|
||||
GifKeyboardPageFragment.Host,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiSearchFragment.Callback
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
@@ -337,7 +347,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private static final int TAKE_PHOTO = 7;
|
||||
private static final int ADD_CONTACT = 8;
|
||||
private static final int PICK_LOCATION = 9;
|
||||
private static final int PICK_GIF = 10;
|
||||
public static final int PICK_GIF = 10;
|
||||
private static final int SMS_DEFAULT = 11;
|
||||
private static final int MEDIA_SENDER = 12;
|
||||
|
||||
@@ -687,12 +697,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
attachmentManager.setLocation(place, getCurrentMediaConstraints());
|
||||
break;
|
||||
case PICK_GIF:
|
||||
setMedia(data.getData(),
|
||||
Objects.requireNonNull(MediaType.from(BlobProvider.getMimeType(data.getData()))),
|
||||
data.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0),
|
||||
data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0),
|
||||
false,
|
||||
true);
|
||||
onGifSelectSuccess(data.getData(),
|
||||
data.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0),
|
||||
data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0));
|
||||
break;
|
||||
case SMS_DEFAULT:
|
||||
initializeSecurity(isSecureText, isDefaultSms);
|
||||
@@ -1097,7 +1104,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
break;
|
||||
case GIF:
|
||||
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getChatColors().asSingleColor());
|
||||
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText);
|
||||
break;
|
||||
case FILE:
|
||||
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
|
||||
@@ -2156,12 +2163,22 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
if (stickersAvailable) {
|
||||
inputPanel.showMediaKeyboardToggle(true);
|
||||
inputPanel.setMediaKeyboardToggleMode(isSystemEmojiPreferred || keyboardMode == MediaKeyboardMode.STICKER);
|
||||
switch (keyboardMode) {
|
||||
case EMOJI:
|
||||
inputPanel.setMediaKeyboardToggleMode(isSystemEmojiPreferred ? KeyboardPage.STICKER : KeyboardPage.EMOJI);
|
||||
break;
|
||||
case STICKER:
|
||||
inputPanel.setMediaKeyboardToggleMode(KeyboardPage.STICKER);
|
||||
break;
|
||||
case GIF:
|
||||
inputPanel.setMediaKeyboardToggleMode(KeyboardPage.GIF);
|
||||
break;
|
||||
}
|
||||
if (stickerIntro) showStickerIntroductionTooltip();
|
||||
}
|
||||
|
||||
if (emojiDrawerStub.resolved()) {
|
||||
initializeMediaKeyboardProviders(emojiDrawerStub.get(), stickersAvailable);
|
||||
initializeMediaKeyboardProviders();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2258,7 +2275,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
private void showStickerIntroductionTooltip() {
|
||||
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
|
||||
inputPanel.setMediaKeyboardToggleMode(true);
|
||||
inputPanel.setMediaKeyboardToggleMode(KeyboardPage.STICKER);
|
||||
|
||||
TooltipPopup.forTarget(inputPanel.getMediaKeyboardToggleAnchorView())
|
||||
.setBackgroundTint(getResources().getColor(R.color.core_ultramarine))
|
||||
@@ -2607,22 +2624,19 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeMediaKeyboardProviders(@NonNull MediaKeyboard mediaKeyboard, boolean stickersAvailable) {
|
||||
boolean isSystemEmojiPreferred = SignalStore.settings().isPreferSystemEmoji();
|
||||
private void initializeMediaKeyboardProviders() {
|
||||
KeyboardPagerViewModel keyboardPagerViewModel = ViewModelProviders.of(this).get(KeyboardPagerViewModel.class);
|
||||
|
||||
if (stickersAvailable) {
|
||||
if (isSystemEmojiPreferred) {
|
||||
mediaKeyboard.setProviders(0, new StickerKeyboardProvider(this, this));
|
||||
} else {
|
||||
MediaKeyboardMode keyboardMode = TextSecurePreferences.getMediaKeyboardMode(this);
|
||||
int index = keyboardMode == MediaKeyboardMode.STICKER ? 1 : 0;
|
||||
|
||||
mediaKeyboard.setProviders(index,
|
||||
new EmojiKeyboardProvider(this, inputPanel),
|
||||
new StickerKeyboardProvider(this, this));
|
||||
}
|
||||
} else if (!isSystemEmojiPreferred) {
|
||||
mediaKeyboard.setProviders(0, new EmojiKeyboardProvider(this, inputPanel));
|
||||
switch (TextSecurePreferences.getMediaKeyboardMode(this)) {
|
||||
case EMOJI:
|
||||
keyboardPagerViewModel.switchToPage(KeyboardPage.EMOJI);
|
||||
break;
|
||||
case STICKER:
|
||||
keyboardPagerViewModel.switchToPage(KeyboardPage.STICKER);
|
||||
break;
|
||||
case GIF:
|
||||
keyboardPagerViewModel.switchToPage(KeyboardPage.GIF);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3105,7 +3119,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
if (!emojiDrawerStub.resolved()) {
|
||||
Boolean stickersAvailable = stickerViewModel.getStickersAvailability().getValue();
|
||||
|
||||
initializeMediaKeyboardProviders(emojiDrawerStub.get(), stickersAvailable == null ? false : stickersAvailable);
|
||||
initializeMediaKeyboardProviders();
|
||||
|
||||
inputPanel.setMediaKeyboard(emojiDrawerStub.get());
|
||||
}
|
||||
@@ -3196,6 +3210,69 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
reactionDelegate.hideMask();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShown() {
|
||||
if (inputPanel != null) {
|
||||
inputPanel.getMediaKeyboardListener().onShown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
if (inputPanel != null) {
|
||||
inputPanel.getMediaKeyboardListener().onHidden();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardChanged(@NonNull KeyboardPage page) {
|
||||
if (inputPanel != null) {
|
||||
inputPanel.getMediaKeyboardListener().onKeyboardChanged(page);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
if (inputPanel != null) {
|
||||
inputPanel.onEmojiSelected(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
if (keyEvent != null) {
|
||||
inputPanel.onKeyEvent(keyEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGifSelectSuccess(@NonNull Uri blobUri, int width, int height) {
|
||||
setMedia(blobUri,
|
||||
Objects.requireNonNull(MediaType.from(BlobProvider.getMimeType(blobUri))),
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMms() {
|
||||
return !isSecureText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openEmojiSearch() {
|
||||
if (emojiDrawerStub.resolved()) {
|
||||
emojiDrawerStub.get().onOpenEmojiSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void closeEmojiSearch() {
|
||||
if (emojiDrawerStub.resolved()) {
|
||||
emojiDrawerStub.get().onCloseEmojiSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private class QuickCameraToggleListener implements OnClickListener {
|
||||
@@ -3289,7 +3366,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
public void onTextChanged(CharSequence s, int start, int before,int count) {}
|
||||
|
||||
@Override
|
||||
public void onFocusChange(View v, boolean hasFocus) {}
|
||||
public void onFocusChange(View v, boolean hasFocus) {
|
||||
if (hasFocus && container.getCurrentInput() == emojiDrawerStub.get()) {
|
||||
container.showSoftkey(composeText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TypingStatusTextWatcher extends SimpleTextWatcher {
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorPreviewView
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
@@ -60,11 +60,12 @@ public class DatabaseFactory {
|
||||
private final SessionDatabase sessionDatabase;
|
||||
private final SearchDatabase searchDatabase;
|
||||
private final StickerDatabase stickerDatabase;
|
||||
private final UnknownStorageIdDatabase storageIdDatabase ;
|
||||
private final UnknownStorageIdDatabase storageIdDatabase;
|
||||
private final RemappedRecordsDatabase remappedRecordsDatabase;
|
||||
private final MentionDatabase mentionDatabase;
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
private final ChatColorsDatabase chatColorsDatabase;
|
||||
private final EmojiSearchDatabase emojiSearchDatabase;
|
||||
|
||||
public static DatabaseFactory getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
@@ -171,6 +172,10 @@ public class DatabaseFactory {
|
||||
return getInstance(context).paymentDatabase;
|
||||
}
|
||||
|
||||
public static EmojiSearchDatabase getEmojiSearchDatabase(Context context) {
|
||||
return getInstance(context).emojiSearchDatabase;
|
||||
}
|
||||
|
||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase();
|
||||
}
|
||||
@@ -229,6 +234,7 @@ public class DatabaseFactory {
|
||||
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
|
||||
this.paymentDatabase = new PaymentDatabase(context, databaseHelper);
|
||||
this.chatColorsDatabase = new ChatColorsDatabase(context, databaseHelper);
|
||||
this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.FtsUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Contains all info necessary for full-text search of emoji tags.
|
||||
*/
|
||||
public class EmojiSearchDatabase extends Database {
|
||||
|
||||
public static final String TABLE_NAME = "emoji_search";
|
||||
|
||||
public static final String LABEL = "label";
|
||||
public static final String EMOJI = "emoji";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE VIRTUAL TABLE " + TABLE_NAME + " USING fts5(" + LABEL + ", " + EMOJI + " UNINDEXED)";
|
||||
|
||||
public EmojiSearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query A search query. Doesn't need any special formatted -- it'll be sanitized.
|
||||
* @return A list of emoji that are related to the search term, ordered by relevance.
|
||||
*/
|
||||
public @NonNull List<String> query(@NonNull String query, int limit) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String matchString = FtsUtil.createPrefixMatchString(query);
|
||||
List<String> results = new LinkedList<>();
|
||||
|
||||
if (TextUtils.isEmpty(matchString)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
String[] projection = new String[] { EMOJI };
|
||||
String selection = LABEL + " MATCH (?)";
|
||||
String[] args = SqlUtil.buildArgs(matchString);
|
||||
|
||||
try (Cursor cursor = db.query(true, TABLE_NAME, projection, selection, args, null, null,"rank", String.valueOf(limit))) {
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(CursorUtil.requireString(cursor, EMOJI));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the content of the current search index and replaces it with the new one.
|
||||
*/
|
||||
public void setSearchIndex(@NonNull List<EmojiSearchData> searchIndex) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
|
||||
for (EmojiSearchData searchData : searchIndex) {
|
||||
for (String label : searchData.getTags()) {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(LABEL, label);
|
||||
values.put(EMOJI, searchData.getEmoji());
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.ChatColorsDatabase;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
@@ -190,8 +191,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
private static final int SERVER_GUID = 99;
|
||||
private static final int CHAT_COLORS = 100;
|
||||
private static final int AVATAR_COLORS = 101;
|
||||
private static final int EMOJI_SEARCH = 102;
|
||||
|
||||
private static final int DATABASE_VERSION = 101;
|
||||
private static final int DATABASE_VERSION = 102;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
@@ -224,6 +226,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
db.execSQL(MentionDatabase.CREATE_TABLE);
|
||||
db.execSQL(PaymentDatabase.CREATE_TABLE);
|
||||
db.execSQL(ChatColorsDatabase.CREATE_TABLE);
|
||||
db.execSQL(EmojiSearchDatabase.CREATE_TABLE);
|
||||
executeStatements(db, SearchDatabase.CREATE_TABLE);
|
||||
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE);
|
||||
|
||||
@@ -1506,6 +1509,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < EMOJI_SEARCH) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE emoji_search USING fts5(label, emoji UNINDEXED)");
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ties together an emoji with it's associated search tags.
|
||||
*/
|
||||
public final class EmojiSearchData {
|
||||
@JsonProperty
|
||||
private String emoji;
|
||||
|
||||
@JsonProperty
|
||||
private List<String> tags;
|
||||
|
||||
public EmojiSearchData() {}
|
||||
|
||||
public @NonNull String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public @NonNull List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ object EmojiFiles {
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLatestEmojiData(context: Context, version: Version): EmojiData? {
|
||||
fun getLatestEmojiData(context: Context, version: Version): ParsedEmojiData? {
|
||||
val names = NameCollection.read(context, version)
|
||||
val dataUuid = names.getUUIDForEmojiData() ?: return null
|
||||
val file = version.getFile(context, dataUuid)
|
||||
|
||||
@@ -92,7 +92,12 @@ class EmojiSource(
|
||||
|
||||
val context = ApplicationDependencies.getApplication()
|
||||
val version = EmojiFiles.Version.readVersion(context) ?: return null
|
||||
val emojiData = EmojiFiles.getLatestEmojiData(context, version)
|
||||
val emojiData = EmojiFiles.getLatestEmojiData(context, version)?.let {
|
||||
it.copy(
|
||||
displayPages = it.displayPages + PAGE_EMOTICONS,
|
||||
dataPages = it.dataPages + PAGE_EMOTICONS
|
||||
)
|
||||
}
|
||||
val density = ScreenDensity.xhdpiRelativeDensityScaleFactor(version.density)
|
||||
|
||||
return emojiData?.let {
|
||||
|
||||
@@ -67,5 +67,6 @@ public class GiphyMp4Fragment extends Fragment {
|
||||
adapter.submitList(images, progressBar::hide);
|
||||
});
|
||||
viewModel.getPagingController().observe(getViewLifecycleOwner(), adapter::setPagingController);
|
||||
viewModel.getPagedData().observe(getViewLifecycleOwner(), unused -> recycler.scrollToPosition(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,15 @@ import org.thoughtcrime.securesms.giph.model.GiphyImage;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* Holds a view which will either play back an MP4 gif or show its still.
|
||||
*/
|
||||
final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
|
||||
|
||||
private static final Projection.Corners CORNERS = new Projection.Corners(ViewUtil.dpToPx(8));
|
||||
|
||||
private final AspectRatioFrameLayout container;
|
||||
private final ImageView stillImage;
|
||||
private final GiphyMp4Adapter.Callback listener;
|
||||
@@ -43,7 +46,7 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM
|
||||
@NonNull GiphyMp4MediaSourceFactory mediaSourceFactory)
|
||||
{
|
||||
super(itemView);
|
||||
this.container = (AspectRatioFrameLayout) itemView;
|
||||
this.container = itemView.findViewById(R.id.container);
|
||||
this.listener = listener;
|
||||
this.stillImage = itemView.findViewById(R.id.still_image);
|
||||
this.placeholder = new ColorDrawable(Util.getRandomElement(ChatColorsPalette.Names.getAll()).getColor(itemView.getContext()));
|
||||
@@ -57,7 +60,6 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM
|
||||
mediaSource = mediaSourceFactory.create(Uri.parse(giphyImage.getMp4PreviewUrl()));
|
||||
|
||||
container.setAspectRatio(aspectRatio);
|
||||
container.setBackground(placeholder);
|
||||
|
||||
loadPlaceholderImage(giphyImage);
|
||||
|
||||
@@ -81,7 +83,7 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM
|
||||
|
||||
@Override
|
||||
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
|
||||
return Projection.relativeToParent(recyclerView, itemView, null);
|
||||
return Projection.relativeToParent(recyclerView, container, CORNERS);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -52,6 +52,10 @@ public final class GiphyMp4ViewModel extends ViewModel {
|
||||
.toList()));
|
||||
}
|
||||
|
||||
LiveData<PagedData<GiphyImage>> getPagedData() {
|
||||
return pagedData;
|
||||
}
|
||||
|
||||
public void updateSearchQuery(@Nullable String query) {
|
||||
if (!Objects.equals(query, this.query)) {
|
||||
this.query = query;
|
||||
|
||||
@@ -5,10 +5,8 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
@@ -17,21 +15,19 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Fragment;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel;
|
||||
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
public class GiphyActivity extends PassphraseRequiredActivity implements GiphyActivityToolbar.OnFilterChangedListener {
|
||||
public class GiphyActivity extends PassphraseRequiredActivity implements KeyboardPageSearchView.Callbacks {
|
||||
|
||||
public static final String EXTRA_IS_MMS = "extra_is_mms";
|
||||
public static final String EXTRA_WIDTH = "extra_width";
|
||||
public static final String EXTRA_HEIGHT = "extra_height";
|
||||
public static final String EXTRA_COLOR = "extra_color";
|
||||
public static final String EXTRA_IS_MMS = "extra_is_mms";
|
||||
public static final String EXTRA_WIDTH = "extra_width";
|
||||
public static final String EXTRA_HEIGHT = "extra_height";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
|
||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
private GiphyMp4ViewModel giphyMp4ViewModel;
|
||||
private AlertDialog progressDialog;
|
||||
@@ -39,7 +35,6 @@ public class GiphyActivity extends PassphraseRequiredActivity implements GiphyAc
|
||||
@Override
|
||||
public void onPreCreate() {
|
||||
dynamicTheme.onCreate(this);
|
||||
dynamicLanguage.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -60,17 +55,10 @@ public class GiphyActivity extends PassphraseRequiredActivity implements GiphyAc
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
GiphyActivityToolbar toolbar = findViewById(R.id.giphy_toolbar);
|
||||
toolbar.setOnFilterChangedListener(this);
|
||||
|
||||
final int conversationColor = getConversationColor();
|
||||
toolbar.setBackgroundColor(conversationColor);
|
||||
WindowUtil.setStatusBarColor(getWindow(), conversationColor);
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
getSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||
KeyboardPageSearchView searchView = findViewById(R.id.giphy_search_text);
|
||||
searchView.setCallbacks(this);
|
||||
searchView.enableBackNavigation();
|
||||
ViewUtil.focusAndShowKeyboard(searchView);
|
||||
}
|
||||
|
||||
private void handleGiphyMp4SaveResult(@NonNull GiphyMp4SaveResult result) {
|
||||
@@ -105,12 +93,23 @@ public class GiphyActivity extends PassphraseRequiredActivity implements GiphyAc
|
||||
Toast.makeText(this, R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
private @ColorInt int getConversationColor() {
|
||||
return getIntent().getIntExtra(EXTRA_COLOR, ActivityCompat.getColor(this, R.color.core_ultramarine));
|
||||
@Override
|
||||
public void onQueryChanged(@NonNull String query) {
|
||||
giphyMp4ViewModel.updateSearchQuery(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFilterChanged(String filter) {
|
||||
giphyMp4ViewModel.updateSearchQuery(filter);
|
||||
public void onNavigationClicked() {
|
||||
ViewUtil.hideKeyboard(this, findViewById(android.R.id.content));
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFocusLost() {}
|
||||
|
||||
@Override
|
||||
public void onFocusGained() {}
|
||||
|
||||
@Override
|
||||
public void onClicked() {}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okio.HashingSink;
|
||||
import okio.Okio;
|
||||
import okio.Sink;
|
||||
import okio.Source;
|
||||
@@ -64,7 +63,7 @@ public class DownloadLatestEmojiDataJob extends BaseJob {
|
||||
private EmojiFiles.Version targetVersion;
|
||||
|
||||
public static void scheduleIfNecessary(@NonNull Context context) {
|
||||
long nextScheduledCheck = SignalStore.emojiValues().getNextScheduledCheck();
|
||||
long nextScheduledCheck = SignalStore.emojiValues().getNextScheduledImageCheck();
|
||||
|
||||
if (nextScheduledCheck <= System.currentTimeMillis()) {
|
||||
Log.i(TAG, "Scheduling DownloadLatestEmojiDataJob.");
|
||||
@@ -79,7 +78,7 @@ public class DownloadLatestEmojiDataJob extends BaseJob {
|
||||
interval = INTERVAL_WITHOUT_REMOTE_DOWNLOAD;
|
||||
}
|
||||
|
||||
SignalStore.emojiValues().setNextScheduledCheck(System.currentTimeMillis() + interval);
|
||||
SignalStore.emojiValues().setNextScheduledImageCheck(System.currentTimeMillis() + interval);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.emoji.Emoji;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.EmojiValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* Downloads a new emoji search index based on our current version and language, if needed.
|
||||
*/
|
||||
public final class EmojiSearchIndexDownloadJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(EmojiSearchIndexDownloadJob.class);
|
||||
|
||||
public static final String KEY = "EmojiSearchIndexDownloadJob";
|
||||
|
||||
private static final long INTERVAL_WITHOUT_INDEX = TimeUnit.DAYS.toMillis(1);
|
||||
private static final long INTERVAL_WITH_INDEX = TimeUnit.DAYS.toMillis(7);
|
||||
|
||||
private EmojiSearchIndexDownloadJob() {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue("EmojiSearchIndexDownloadJob")
|
||||
.setMaxInstancesForFactory(2)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build());
|
||||
}
|
||||
|
||||
private EmojiSearchIndexDownloadJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
public static void scheduleImmediately() {
|
||||
ApplicationDependencies.getJobManager().add(new EmojiSearchIndexDownloadJob());
|
||||
}
|
||||
|
||||
public static void scheduleIfNecessary() {
|
||||
long timeSinceCheck = System.currentTimeMillis() - SignalStore.emojiValues().getLastSearchIndexCheck();
|
||||
boolean needsCheck = false;
|
||||
|
||||
if (SignalStore.emojiValues().hasSearchIndex()) {
|
||||
needsCheck = timeSinceCheck > INTERVAL_WITH_INDEX;
|
||||
} else {
|
||||
needsCheck = timeSinceCheck > INTERVAL_WITHOUT_INDEX;
|
||||
}
|
||||
|
||||
if (needsCheck) {
|
||||
Log.i(TAG, "Need to check. It's been " + timeSinceCheck + " ms since the last check.");
|
||||
scheduleImmediately();
|
||||
} else {
|
||||
Log.d(TAG, "Do not need to check. It's been " + timeSinceCheck + " ms since the last check.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
OkHttpClient client = ApplicationDependencies.getOkHttpClient();
|
||||
|
||||
Manifest manifest = downloadManifest(client);
|
||||
|
||||
Locale locale = DynamicLanguageContextWrapper.getUsersSelectedLocale(context);
|
||||
String remoteLanguage = findMatchingLanguage(locale, manifest.getLanguages());
|
||||
|
||||
if (manifest.getVersion() == SignalStore.emojiValues().getSearchVersion() &&
|
||||
remoteLanguage.equals(SignalStore.emojiValues().getSearchLanguage()))
|
||||
{
|
||||
Log.i(TAG, "Already using the latest version of " + manifest.getVersion() + " with the correct language " + remoteLanguage);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Need to get a new search index. Downloading version: " + manifest.getVersion() + ", language: " + remoteLanguage);
|
||||
|
||||
List<EmojiSearchData> searchIndex = downloadSearchIndex(client, manifest.getVersion(), remoteLanguage);
|
||||
|
||||
DatabaseFactory.getEmojiSearchDatabase(context).setSearchIndex(searchIndex);
|
||||
SignalStore.emojiValues().onSearchIndexUpdated(manifest.getVersion(), remoteLanguage);
|
||||
|
||||
Log.i(TAG, "Success! Now at version: " + manifest.getVersion() + ", language: " + remoteLanguage);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof IOException && !(e instanceof NonSuccessfulResponseCodeException);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
|
||||
}
|
||||
|
||||
private static @NonNull Manifest downloadManifest(@NonNull OkHttpClient client) throws IOException {
|
||||
String url = "https://updates.signal.org/dynamic/android/emoji/search/manifest.json";
|
||||
String body = downloadFile(client, url);
|
||||
|
||||
return JsonUtil.fromJson(body, Manifest.class);
|
||||
}
|
||||
|
||||
private static @NonNull List<EmojiSearchData> downloadSearchIndex(@NonNull OkHttpClient client, int version, @NonNull String language) throws IOException {
|
||||
String url = "https://updates.signal.org/static/android/emoji/search/" + version + "/" + language + ".json";
|
||||
String body = downloadFile(client, url);
|
||||
|
||||
return Arrays.asList(JsonUtil.fromJson(body, EmojiSearchData[].class));
|
||||
}
|
||||
|
||||
private static @NonNull String downloadFile(@NonNull OkHttpClient client, @NonNull String url) throws IOException {
|
||||
Call call = client.newCall(new Request.Builder().url(url).build());
|
||||
Response response = call.execute();
|
||||
|
||||
if (response.code() != 200) {
|
||||
throw new NonSuccessfulResponseCodeException(response.code());
|
||||
}
|
||||
|
||||
if (response.body() == null) {
|
||||
throw new NonSuccessfulResponseCodeException(404, "Missing body!");
|
||||
}
|
||||
|
||||
return response.body().string();
|
||||
}
|
||||
|
||||
private static @NonNull String findMatchingLanguage(@NonNull Locale locale, List<String> languages) {
|
||||
String parentLanguage = null;
|
||||
|
||||
for (String language : languages) {
|
||||
Locale testLocale = new Locale(language);
|
||||
|
||||
if (locale.getLanguage().equals(testLocale.getLanguage())) {
|
||||
if (locale.getVariant().equals(testLocale.getVariant())) {
|
||||
Log.d(TAG, "Found an exact match: " + language);
|
||||
return language;
|
||||
} else if (locale.getVariant().equals("")) {
|
||||
Log.d(TAG, "Found the parent language: " + language);
|
||||
parentLanguage = language;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parentLanguage != null) {
|
||||
Log.i(TAG, "No exact match found. Using parent language: " + parentLanguage);
|
||||
return parentLanguage;
|
||||
} else if (languages.contains("en")) {
|
||||
Log.w(TAG, "No match, so falling back to en locale.");
|
||||
return "en";
|
||||
} else if (languages.contains("en_US")) {
|
||||
Log.w(TAG, "No match, so falling back to en_US locale.");
|
||||
return "en_US";
|
||||
} else {
|
||||
Log.w(TAG, "No match and no english fallback! Must return no language!");
|
||||
return EmojiValues.NO_LANGUAGE;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Manifest {
|
||||
@JsonProperty
|
||||
private int version;
|
||||
|
||||
@JsonProperty
|
||||
private List<String> languages;
|
||||
|
||||
public Manifest() {}
|
||||
|
||||
public int getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public @NonNull List<String> getLanguages() {
|
||||
return languages != null ? languages : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<EmojiSearchIndexDownloadJob> {
|
||||
@Override
|
||||
public @NonNull EmojiSearchIndexDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new EmojiSearchIndexDownloadJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ public final class JobManagerFactories {
|
||||
put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory());
|
||||
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
|
||||
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
|
||||
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
|
||||
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
||||
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
|
||||
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
enum class KeyboardPage {
|
||||
EMOJI,
|
||||
STICKER,
|
||||
GIF
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
interface KeyboardPageCategoryIconMappingModel<T : KeyboardPageCategoryIconMappingModel<T>> : MappingModel<T> {
|
||||
val key: String
|
||||
val selected: Boolean
|
||||
|
||||
fun getIcon(context: Context): Drawable
|
||||
}
|
||||
|
||||
class KeyboardPageCategoryIconViewHolder<T : KeyboardPageCategoryIconMappingModel<T>>(itemView: View, private val onPageSelected: (String) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val iconView: AppCompatImageView = itemView.findViewById(R.id.category_icon)
|
||||
private val iconSelected: View = itemView.findViewById(R.id.category_icon_selected)
|
||||
|
||||
override fun bind(model: T) {
|
||||
itemView.setOnClickListener {
|
||||
onPageSelected(model.key)
|
||||
}
|
||||
|
||||
iconView.setImageDrawable(model.getIcon(context))
|
||||
iconView.isSelected = model.selected
|
||||
iconSelected.isSelected = model.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) {
|
||||
|
||||
private lateinit var emojiButton: View
|
||||
private lateinit var stickerButton: View
|
||||
private lateinit var gifButton: View
|
||||
private lateinit var viewModel: KeyboardPagerViewModel
|
||||
|
||||
private val fragments: MutableMap<KClass<*>, Fragment> = mutableMapOf()
|
||||
private var currentFragment: Fragment? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
emojiButton = view.findViewById(R.id.keyboard_pager_fragment_emoji)
|
||||
stickerButton = view.findViewById(R.id.keyboard_pager_fragment_sticker)
|
||||
gifButton = view.findViewById(R.id.keyboard_pager_fragment_gif)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity())[KeyboardPagerViewModel::class.java]
|
||||
|
||||
viewModel.page().observe(viewLifecycleOwner, this::onPageSelected)
|
||||
viewModel.pages().observe(viewLifecycleOwner) { pages ->
|
||||
emojiButton.visible = pages.contains(KeyboardPage.EMOJI) && pages.size > 1
|
||||
stickerButton.visible = pages.contains(KeyboardPage.STICKER) && pages.size > 1
|
||||
gifButton.visible = pages.contains(KeyboardPage.GIF) && pages.size > 1
|
||||
}
|
||||
|
||||
emojiButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.EMOJI) }
|
||||
stickerButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.STICKER) }
|
||||
gifButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.GIF) }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel.page().value?.let(this::onPageSelected)
|
||||
}
|
||||
|
||||
private fun onPageSelected(page: KeyboardPage) {
|
||||
emojiButton.isSelected = page == KeyboardPage.EMOJI
|
||||
stickerButton.isSelected = page == KeyboardPage.STICKER
|
||||
gifButton.isSelected = page == KeyboardPage.GIF
|
||||
|
||||
when (page) {
|
||||
KeyboardPage.EMOJI -> displayEmojiPage()
|
||||
KeyboardPage.GIF -> displayGifPage()
|
||||
KeyboardPage.STICKER -> displayStickerPage()
|
||||
}
|
||||
|
||||
findListener<MediaKeyboard.MediaKeyboardListener>()?.onKeyboardChanged(page)
|
||||
}
|
||||
|
||||
private fun displayEmojiPage() = displayPage(::EmojiKeyboardPageFragment)
|
||||
|
||||
private fun displayGifPage() = displayPage(::GifKeyboardPageFragment)
|
||||
|
||||
private fun displayStickerPage() = displayPage(::StickerKeyboardPageFragment)
|
||||
|
||||
private inline fun <reified F : Fragment> displayPage(fragmentFactory: () -> F) {
|
||||
if (currentFragment is F) {
|
||||
return
|
||||
}
|
||||
|
||||
val transaction = childFragmentManager.beginTransaction()
|
||||
|
||||
currentFragment?.let { transaction.hide(it) }
|
||||
|
||||
var fragment = fragments[F::class]
|
||||
if (fragment == null) {
|
||||
fragment = fragmentFactory()
|
||||
transaction.add(R.id.fragment_container, fragment)
|
||||
fragments[F::class] = fragment
|
||||
} else {
|
||||
transaction.show(fragment)
|
||||
}
|
||||
|
||||
currentFragment = fragment
|
||||
transaction.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (isAdded && view != null) {
|
||||
viewModel.page().value?.let(this::onPageSelected)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
if (isAdded && view != null) {
|
||||
val transaction = childFragmentManager.beginTransaction()
|
||||
fragments.values.forEach { transaction.remove(it) }
|
||||
transaction.commitAllowingStateLoss()
|
||||
currentFragment = null
|
||||
fragments.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.stickers.StickerSearchRepository
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
|
||||
class KeyboardPagerViewModel : ViewModel() {
|
||||
|
||||
private val page: DefaultValueLiveData<KeyboardPage>
|
||||
private val pages: DefaultValueLiveData<Set<KeyboardPage>>
|
||||
|
||||
init {
|
||||
val startingPages: MutableSet<KeyboardPage> = KeyboardPage.values().toMutableSet()
|
||||
if (SignalStore.settings().isPreferSystemEmoji) {
|
||||
startingPages.remove(KeyboardPage.EMOJI)
|
||||
}
|
||||
pages = DefaultValueLiveData(startingPages)
|
||||
page = DefaultValueLiveData(startingPages.first())
|
||||
|
||||
StickerSearchRepository(ApplicationDependencies.getApplication()).getStickerFeatureAvailability { available ->
|
||||
if (!available) {
|
||||
val updatedPages = pages.value.toMutableSet().apply { remove(KeyboardPage.STICKER) }
|
||||
pages.postValue(updatedPages)
|
||||
if (page.value == KeyboardPage.STICKER) {
|
||||
switchToPage(KeyboardPage.GIF)
|
||||
switchToPage(KeyboardPage.EMOJI)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun page(): LiveData<KeyboardPage> = page
|
||||
fun pages(): LiveData<Set<KeyboardPage>> = pages
|
||||
|
||||
fun setOnlyPage(page: KeyboardPage) {
|
||||
pages.value = setOf(page)
|
||||
switchToPage(page)
|
||||
}
|
||||
|
||||
fun switchToPage(page: KeyboardPage) {
|
||||
if (this.pages.value.contains(page) && this.page.value != page) {
|
||||
this.page.value = page
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
/**
|
||||
* Given an input type [T], find an instance of it first looking through all
|
||||
* parents, and then the activity.
|
||||
*
|
||||
* @return First instance found of type [T] or null
|
||||
*/
|
||||
inline fun <reified T> Fragment.findListener(): T? {
|
||||
var parent: Fragment? = parentFragment
|
||||
while (parent != null) {
|
||||
if (parent is T) {
|
||||
return parent
|
||||
}
|
||||
parent = parent.parentFragment
|
||||
}
|
||||
|
||||
return requireActivity() as? T
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class EmojiKeyboardPageAdapter(
|
||||
private val emojiSelectionListener: EmojiKeyboardProvider.EmojiEventListener,
|
||||
private val variationSelectorListener: EmojiPageViewGridAdapter.VariationSelectorListener,
|
||||
private val searchCallbacks: KeyboardPageSearchView.Callbacks
|
||||
) : MappingAdapter() {
|
||||
|
||||
init {
|
||||
registerFactory(EmojiPageMappingModel::class.java) { parent ->
|
||||
val pageView = EmojiPageView(parent.context, emojiSelectionListener, variationSelectorListener, true, searchCallbacks)
|
||||
|
||||
val layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
pageView.layoutParams = layoutParams
|
||||
|
||||
ViewHolder(pageView)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(
|
||||
private val emojiPageView: EmojiPageView,
|
||||
) : MappingViewHolder<EmojiPageMappingModel>(emojiPageView) {
|
||||
|
||||
override fun bind(model: EmojiPageMappingModel) {
|
||||
emojiPageView.bindSearchableAdapter(model.emojiPageModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconViewHolder
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
|
||||
class EmojiKeyboardPageCategoriesAdapter(private val onPageSelected: (String) -> Unit) : MappingAdapter() {
|
||||
init {
|
||||
registerFactory(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
|
||||
registerFactory(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.AttrRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconMappingModel
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
sealed class EmojiKeyboardPageCategoryMappingModel(
|
||||
override val key: String,
|
||||
@AttrRes val iconId: Int,
|
||||
override val selected: Boolean
|
||||
) : KeyboardPageCategoryIconMappingModel<EmojiKeyboardPageCategoryMappingModel> {
|
||||
|
||||
override fun getIcon(context: Context): Drawable {
|
||||
return requireNotNull(ThemeUtil.getThemedDrawable(context, iconId))
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem.key == key
|
||||
}
|
||||
|
||||
class RecentsMappingModel(selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(KEY, R.attr.emoji_category_recent, selected) {
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem is RecentsMappingModel && super.areContentsTheSame(newItem)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY = "Recents"
|
||||
}
|
||||
}
|
||||
|
||||
class EmojiCategoryMappingModel(private val emojiCategory: EmojiCategory, selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(emojiCategory.key, emojiCategory.icon, selected) {
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem is EmojiCategoryMappingModel &&
|
||||
super.areContentsTheSame(newItem) &&
|
||||
newItem.emojiCategory == emojiCategory
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) && selected == newItem.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
private val DELETE_KEY_EVENT: KeyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)
|
||||
|
||||
class EmojiKeyboardPageFragment : Fragment(R.layout.keyboard_pager_emoji_page_fragment), EmojiKeyboardProvider.EmojiEventListener, EmojiPageViewGridAdapter.VariationSelectorListener {
|
||||
|
||||
private lateinit var viewModel: EmojiKeyboardPageViewModel
|
||||
private lateinit var emojiPager: ViewPager2
|
||||
private lateinit var searchView: View
|
||||
private lateinit var emojiCategoriesRecycler: RecyclerView
|
||||
private lateinit var backspaceView: View
|
||||
private lateinit var eventListener: EmojiKeyboardProvider.EmojiEventListener
|
||||
private lateinit var callback: Callback
|
||||
private lateinit var pagesAdapter: EmojiKeyboardPageAdapter
|
||||
private lateinit var categoriesAdapter: EmojiKeyboardPageCategoriesAdapter
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
emojiPager = view.findViewById(R.id.emoji_pager)
|
||||
searchView = view.findViewById(R.id.emoji_search)
|
||||
emojiCategoriesRecycler = view.findViewById(R.id.emoji_categories_recycler)
|
||||
backspaceView = view.findViewById(R.id.emoji_backspace)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(EmojiKeyboardPageViewModel::class.java)
|
||||
|
||||
pagesAdapter = EmojiKeyboardPageAdapter(this, this, EmojiKeyboardPageSearchViewCallbacks())
|
||||
|
||||
categoriesAdapter = EmojiKeyboardPageCategoriesAdapter { key ->
|
||||
viewModel.onKeySelected(key)
|
||||
|
||||
val page = pagesAdapter.currentList.indexOfFirst {
|
||||
(it as EmojiPageMappingModel).key == key
|
||||
}
|
||||
|
||||
if (emojiPager.currentItem != page) {
|
||||
emojiPager.currentItem = page
|
||||
}
|
||||
}
|
||||
|
||||
emojiPager.adapter = pagesAdapter
|
||||
emojiCategoriesRecycler.adapter = categoriesAdapter
|
||||
|
||||
searchView.setOnClickListener {
|
||||
callback.openEmojiSearch()
|
||||
}
|
||||
|
||||
backspaceView.setOnClickListener { eventListener.onKeyEvent(DELETE_KEY_EVENT) }
|
||||
|
||||
viewModel.categories.observe(viewLifecycleOwner) { categories ->
|
||||
categoriesAdapter.submitList(categories)
|
||||
}
|
||||
|
||||
viewModel.pages.observe(viewLifecycleOwner) { pages ->
|
||||
val registerPageCallback: Boolean = pagesAdapter.currentList.isEmpty() && pages.isNotEmpty()
|
||||
pagesAdapter.submitList(pages) { updatePagerPosition(registerPageCallback) }
|
||||
}
|
||||
|
||||
viewModel.selectedKey.observe(viewLifecycleOwner) { updateCategoryTab() }
|
||||
|
||||
eventListener = findListener() ?: throw AssertionError("No emoji listener found")
|
||||
}
|
||||
|
||||
private fun updateCategoryTab() {
|
||||
emojiCategoriesRecycler.post {
|
||||
val index: Int = categoriesAdapter.currentList.indexOfFirst { (it as? EmojiKeyboardPageCategoryMappingModel)?.key == viewModel.selectedKey.value }
|
||||
|
||||
if (index != -1) {
|
||||
emojiCategoriesRecycler.smoothScrollToPosition(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePagerPosition(registerPageCallback: Boolean) {
|
||||
val page = pagesAdapter.currentList.indexOfFirst {
|
||||
(it as EmojiPageMappingModel).key == viewModel.selectedKey.value
|
||||
}
|
||||
|
||||
if (emojiPager.currentItem != page && page != -1) {
|
||||
emojiPager.setCurrentItem(page, false)
|
||||
}
|
||||
|
||||
if (registerPageCallback) {
|
||||
emojiPager.registerOnPageChangeCallback(PageChanged(pagesAdapter))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(emoji: String) {
|
||||
SignalStore.emojiValues().setPreferredVariation(emoji)
|
||||
eventListener.onEmojiSelected(emoji)
|
||||
viewModel.addToRecents(emoji)
|
||||
}
|
||||
|
||||
override fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||
eventListener.onKeyEvent(keyEvent)
|
||||
}
|
||||
|
||||
override fun onVariationSelectorStateChanged(open: Boolean) {
|
||||
emojiPager.isUserInputEnabled = !open
|
||||
}
|
||||
|
||||
private inner class PageChanged(private val adapter: EmojiKeyboardPageAdapter) : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
val mappingModel: EmojiPageMappingModel = adapter.currentList[position] as EmojiPageMappingModel
|
||||
viewModel.onKeySelected(mappingModel.key)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class EmojiKeyboardPageSearchViewCallbacks : KeyboardPageSearchView.Callbacks {
|
||||
override fun onClicked() {
|
||||
callback.openEmojiSearch()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun openEmojiSearch()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
class EmojiKeyboardPageViewModel : ViewModel() {
|
||||
|
||||
private val internalSelectedKey = DefaultValueLiveData<String>(getStartingTab())
|
||||
|
||||
val selectedKey: LiveData<String>
|
||||
get() = internalSelectedKey
|
||||
|
||||
val categories: LiveData<MappingModelList> = Transformations.map(internalSelectedKey) { selected ->
|
||||
MappingModelList().apply {
|
||||
add(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel(selected == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY))
|
||||
|
||||
EmojiCategory.values().forEach {
|
||||
add(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel(it, it.key == selected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pages: LiveData<MappingModelList> = Transformations.map(categories) { categories ->
|
||||
MappingModelList().apply {
|
||||
categories.forEach {
|
||||
add(getPageForCategory(it as EmojiKeyboardPageCategoryMappingModel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onKeySelected(key: String) {
|
||||
internalSelectedKey.value = key
|
||||
}
|
||||
|
||||
private fun getPageForCategory(mappingModel: EmojiKeyboardPageCategoryMappingModel): EmojiPageMappingModel {
|
||||
val page = if (mappingModel.key == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY) {
|
||||
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)
|
||||
} else {
|
||||
EmojiSource.latest.displayPages.first { it.iconAttr == mappingModel.iconId }
|
||||
}
|
||||
|
||||
return EmojiPageMappingModel(mappingModel.key, page)
|
||||
}
|
||||
|
||||
fun addToRecents(emoji: String) {
|
||||
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY).onCodePointSelected(emoji)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getStartingTab(): String {
|
||||
return if (RecentEmojiPageModel.hasRecents(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)) {
|
||||
EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY
|
||||
} else {
|
||||
EmojiCategory.PEOPLE.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
class EmojiPageMappingModel(val key: String, val emojiPageModel: EmojiPageModel) : MappingModel<EmojiPageMappingModel> {
|
||||
override fun areItemsTheSame(newItem: EmojiPageMappingModel): Boolean {
|
||||
return key == newItem.key
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: EmojiPageMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) &&
|
||||
newItem.emojiPageModel.spriteUri == emojiPageModel.spriteUri &&
|
||||
newItem.emojiPageModel.iconAttr == emojiPageModel.iconAttr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
private const val REVEAL_DURATION = 250L
|
||||
|
||||
/**
|
||||
* Search bar to be used in the various keyboard views (emoji, sticker, gif)
|
||||
*/
|
||||
class KeyboardPageSearchView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
var callbacks: Callbacks? = null
|
||||
|
||||
private var state: State = State.HIDE_REQUESTED
|
||||
private var targetInputWidth: Int = -1
|
||||
|
||||
private val navButton: AppCompatImageView
|
||||
private val clearButton: AppCompatImageView
|
||||
private val input: EditText
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.keyboard_pager_search_bar, this)
|
||||
|
||||
navButton = findViewById(R.id.emoji_search_nav_icon)
|
||||
clearButton = findViewById(R.id.emoji_search_clear_icon)
|
||||
input = findViewById(R.id.emoji_search_entry)
|
||||
|
||||
input.addTextChangedListener {
|
||||
if (it.isNullOrEmpty()) {
|
||||
clearButton.setImageDrawable(null)
|
||||
clearButton.isClickable = false
|
||||
} else {
|
||||
clearButton.setImageResource(R.drawable.ic_x)
|
||||
clearButton.isClickable = true
|
||||
}
|
||||
|
||||
if (it.isNullOrEmpty()) {
|
||||
callbacks?.onQueryChanged("")
|
||||
} else {
|
||||
callbacks?.onQueryChanged(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
input.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
callbacks?.onFocusGained()
|
||||
} else {
|
||||
callbacks?.onFocusLost()
|
||||
}
|
||||
}
|
||||
|
||||
clearButton.setOnClickListener {
|
||||
input.text.clear()
|
||||
}
|
||||
|
||||
context.obtainStyledAttributes(attrs, R.styleable.KeyboardPageSearchView, 0, 0).use { typedArray ->
|
||||
val showAlways: Boolean = typedArray.getBoolean(R.styleable.KeyboardPageSearchView_show_always, false)
|
||||
if (showAlways) {
|
||||
alpha = 1f
|
||||
state = State.SHOW_REQUESTED
|
||||
} else {
|
||||
alpha = 0f
|
||||
input.layoutParams = input.layoutParams.apply { width = 1 }
|
||||
state = State.HIDE_REQUESTED
|
||||
}
|
||||
|
||||
input.hint = typedArray.getString(R.styleable.KeyboardPageSearchView_search_hint) ?: ""
|
||||
|
||||
val backgroundTint = typedArray.getColor(R.styleable.KeyboardPageSearchView_search_bar_tint, ContextCompat.getColor(context, R.color.signal_background_primary))
|
||||
val backgroundTintList = ColorStateList.valueOf(backgroundTint)
|
||||
input.background = ColorDrawable(backgroundTint)
|
||||
ViewCompat.setBackgroundTintList(findViewById(R.id.emoji_search_nav), backgroundTintList)
|
||||
ViewCompat.setBackgroundTintList(findViewById(R.id.emoji_search_clear), backgroundTintList)
|
||||
|
||||
val iconTint = typedArray.getColorStateList(R.styleable.KeyboardPageSearchView_search_icon_tint) ?: ContextCompat.getColorStateList(context, R.color.signal_icon_tint_primary)
|
||||
ImageViewCompat.setImageTintList(navButton, iconTint)
|
||||
ImageViewCompat.setImageTintList(clearButton, iconTint)
|
||||
|
||||
val clickOnly: Boolean = typedArray.getBoolean(R.styleable.KeyboardPageSearchView_click_only, false)
|
||||
if (clickOnly) {
|
||||
val clickIntercept: View = findViewById(R.id.keyboard_search_click_only)
|
||||
clickIntercept.visible = true
|
||||
clickIntercept.setOnClickListener { callbacks?.onClicked() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showRequested(): Boolean = state == State.SHOW_REQUESTED
|
||||
|
||||
fun enableBackNavigation() {
|
||||
navButton.setImageResource(R.drawable.ic_arrow_left_24)
|
||||
navButton.setOnClickListener {
|
||||
callbacks?.onNavigationClicked()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
targetInputWidth = w - ViewUtil.dpToPx(32) - ViewUtil.dpToPx(90)
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (state == State.SHOW_REQUESTED) {
|
||||
return
|
||||
}
|
||||
|
||||
visibility = VISIBLE
|
||||
state = State.SHOW_REQUESTED
|
||||
|
||||
post {
|
||||
animate()
|
||||
.setDuration(REVEAL_DURATION)
|
||||
.alpha(1f)
|
||||
.setListener(null)
|
||||
|
||||
val resizeAnimation = ResizeAnimation(input, targetInputWidth, input.measuredHeight)
|
||||
resizeAnimation.duration = REVEAL_DURATION
|
||||
input.startAnimation(resizeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
if (state == State.HIDE_REQUESTED) {
|
||||
return
|
||||
}
|
||||
|
||||
state = State.HIDE_REQUESTED
|
||||
|
||||
post {
|
||||
animate()
|
||||
.setDuration(REVEAL_DURATION)
|
||||
.alpha(0f)
|
||||
.setListener(object : AnimationCompleteListener() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
visibility = INVISIBLE
|
||||
}
|
||||
})
|
||||
|
||||
val resizeAnimation = ResizeAnimation(input, 1, input.measuredHeight)
|
||||
resizeAnimation.duration = REVEAL_DURATION
|
||||
input.startAnimation(resizeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
fun presentForEmojiSearch() {
|
||||
ViewUtil.focusAndShowKeyboard(input)
|
||||
enableBackNavigation()
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onFocusLost() = Unit
|
||||
fun onFocusGained() = Unit
|
||||
fun onNavigationClicked() = Unit
|
||||
fun onQueryChanged(query: String) = Unit
|
||||
fun onClicked() = Unit
|
||||
}
|
||||
|
||||
enum class State {
|
||||
SHOW_REQUESTED,
|
||||
HIDE_REQUESTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
class EmojiSearchFragment : Fragment(R.layout.emoji_search_fragment), EmojiPageViewGridAdapter.VariationSelectorListener {
|
||||
|
||||
private lateinit var viewModel: EmojiSearchViewModel
|
||||
private lateinit var callback: Callback
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val repository = EmojiSearchRepository(requireContext())
|
||||
val factory = EmojiSearchViewModel.Factory(repository)
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory)[EmojiSearchViewModel::class.java]
|
||||
|
||||
val eventListener: EmojiKeyboardProvider.EmojiEventListener = requireNotNull(findListener())
|
||||
val searchBar: KeyboardPageSearchView = view.findViewById(R.id.emoji_search_view)
|
||||
val resultsContainer: FrameLayout = view.findViewById(R.id.emoji_search_results_container)
|
||||
val noResults: TextView = view.findViewById(R.id.emoji_search_empty)
|
||||
val emojiPageView = EmojiPageView(requireContext(), eventListener, this, true, null, LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false), R.layout.emoji_search_result_display_item)
|
||||
|
||||
resultsContainer.addView(emojiPageView)
|
||||
|
||||
searchBar.presentForEmojiSearch()
|
||||
searchBar.callbacks = SearchCallbacks()
|
||||
|
||||
viewModel.pageModel.observe(viewLifecycleOwner) { pageModel ->
|
||||
emojiPageView.setModel(pageModel)
|
||||
|
||||
if (pageModel.emoji.isNotEmpty() || pageModel.iconAttr == R.attr.emoji_category_recent) {
|
||||
emojiPageView.visibility = View.VISIBLE
|
||||
noResults.visibility = View.GONE
|
||||
} else {
|
||||
emojiPageView.visibility = View.INVISIBLE
|
||||
noResults.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SearchCallbacks : KeyboardPageSearchView.Callbacks {
|
||||
override fun onNavigationClicked() {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
callback.closeEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onQueryChanged(query: String) {
|
||||
viewModel.onQueryChanged(query)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun closeEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onVariationSelectorStateChanged(open: Boolean) = Unit
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.emoji.Emoji
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource
|
||||
|
||||
private const val MINIMUM_QUERY_THRESHOLD = 1
|
||||
private const val EMOJI_SEARCH_LIMIT = 20
|
||||
|
||||
class EmojiSearchRepository(private val context: Context) {
|
||||
|
||||
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
|
||||
|
||||
fun submitQuery(query: String, consumer: (EmojiPageModel) -> Unit) {
|
||||
if (query.length < MINIMUM_QUERY_THRESHOLD) {
|
||||
consumer(RecentEmojiPageModel(context, EmojiKeyboardProvider.RECENT_STORAGE_KEY))
|
||||
} else {
|
||||
SignalExecutors.SERIAL.execute {
|
||||
val emoji: List<String> = emojiSearchDatabase.query(query, EMOJI_SEARCH_LIMIT)
|
||||
|
||||
val variationMap: Map<String, String> = EmojiSource.latest.variationMap
|
||||
val emojiVariationSets: MutableMap<String, LinkedHashSet<String>> = mutableMapOf()
|
||||
|
||||
variationMap
|
||||
.filterKeys { emoji.contains(it) }
|
||||
.forEach { (variation, canonical) ->
|
||||
val set: LinkedHashSet<String> = emojiVariationSets.getOrDefault(canonical, linkedSetOf())
|
||||
|
||||
set.add(variation)
|
||||
emojiVariationSets[canonical] = set
|
||||
}
|
||||
|
||||
val displayEmoji: List<Emoji> = emoji.map { canonical ->
|
||||
val variationSet: LinkedHashSet<String> = linkedSetOf(canonical).apply {
|
||||
addAll(emojiVariationSets.getOrDefault(canonical, linkedSetOf()))
|
||||
}
|
||||
|
||||
Emoji(variationSet.toList())
|
||||
}
|
||||
|
||||
consumer(EmojiSearchResultsPageModel(emoji, displayEmoji))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiSearchResultsPageModel(
|
||||
private val emoji: List<String>,
|
||||
private val displayEmoji: List<Emoji>
|
||||
) : EmojiPageModel {
|
||||
override fun getIconAttr(): Int = -1
|
||||
|
||||
override fun getEmoji(): List<String> = emoji
|
||||
|
||||
override fun getDisplayEmoji(): List<Emoji> = displayEmoji
|
||||
|
||||
override fun getSpriteUri(): Uri? = null
|
||||
|
||||
override fun isDynamic(): Boolean = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
|
||||
class EmojiSearchViewModel(private val repository: EmojiSearchRepository) : ViewModel() {
|
||||
|
||||
private val internalPageModel = MutableLiveData<EmojiPageModel>()
|
||||
|
||||
val pageModel: LiveData<EmojiPageModel> = internalPageModel
|
||||
|
||||
init {
|
||||
onQueryChanged("")
|
||||
}
|
||||
|
||||
fun onQueryChanged(query: String) {
|
||||
repository.submitQuery(query, internalPageModel::postValue)
|
||||
}
|
||||
|
||||
class Factory(private val repository: EmojiSearchRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(EmojiSearchViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Fragment
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mms.AttachmentManager
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
|
||||
class GifKeyboardPageFragment : LoggingFragment(R.layout.gif_keyboard_page_fragment) {
|
||||
|
||||
private lateinit var host: Host
|
||||
private lateinit var quickSearchAdapter: GifQuickSearchAdapter
|
||||
private lateinit var giphyMp4ViewModel: GiphyMp4ViewModel
|
||||
|
||||
private lateinit var viewModel: GifKeyboardPageViewModel
|
||||
|
||||
private var progressDialog: AlertDialog? = null
|
||||
private lateinit var quickSearchList: RecyclerView
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
host = findListener<Host>() ?: throw AssertionError("Parent fragment or activity must implement Host")
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.gif_keyboard_giphy_frame, GiphyMp4Fragment.create(host.isMms()))
|
||||
.commitAllowingStateLoss()
|
||||
|
||||
val searchKeyboard: KeyboardPageSearchView = view.findViewById(R.id.gif_keyboard_search_text)
|
||||
searchKeyboard.callbacks = object : KeyboardPageSearchView.Callbacks {
|
||||
override fun onClicked() {
|
||||
openGifSearch()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.gif_keyboard_search).setOnClickListener { openGifSearch() }
|
||||
|
||||
quickSearchList = view.findViewById(R.id.gif_keyboard_quick_search_recycler)
|
||||
quickSearchAdapter = GifQuickSearchAdapter(this::onQuickSearchSelected)
|
||||
quickSearchList.adapter = quickSearchAdapter
|
||||
|
||||
giphyMp4ViewModel = ViewModelProviders.of(requireActivity(), GiphyMp4ViewModel.Factory(host.isMms())).get(GiphyMp4ViewModel::class.java)
|
||||
giphyMp4ViewModel.saveResultEvents.observe(viewLifecycleOwner, this::handleGiphyMp4SaveResult)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(GifKeyboardPageViewModel::class.java)
|
||||
updateQuickSearchTabs()
|
||||
}
|
||||
|
||||
private fun onQuickSearchSelected(gifQuickSearchOption: GifQuickSearchOption) {
|
||||
if (viewModel.selectedTab == gifQuickSearchOption) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.selectedTab = gifQuickSearchOption
|
||||
giphyMp4ViewModel.updateSearchQuery(gifQuickSearchOption.query)
|
||||
|
||||
updateQuickSearchTabs()
|
||||
}
|
||||
|
||||
private fun updateQuickSearchTabs() {
|
||||
val quickSearches: List<GifQuickSearch> = GifQuickSearchOption.ranked
|
||||
.map { search -> GifQuickSearch(search, search == viewModel.selectedTab) }
|
||||
|
||||
quickSearchAdapter.submitList(quickSearches, this::scrollToTab)
|
||||
}
|
||||
|
||||
private fun scrollToTab() {
|
||||
quickSearchList.post { quickSearchList.smoothScrollToPosition(GifQuickSearchOption.ranked.indexOf(viewModel.selectedTab)) }
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4SaveResult(result: GiphyMp4SaveResult) {
|
||||
if (result is GiphyMp4SaveResult.Success) {
|
||||
hideProgressDialog()
|
||||
handleGiphyMp4SuccessfulResult(result)
|
||||
} else if (result is GiphyMp4SaveResult.Error) {
|
||||
hideProgressDialog()
|
||||
handleGiphyMp4ErrorResult()
|
||||
} else {
|
||||
progressDialog = SimpleProgressDialog.show(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideProgressDialog() {
|
||||
progressDialog?.dismiss()
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4SuccessfulResult(success: GiphyMp4SaveResult.Success) {
|
||||
host.onGifSelectSuccess(success.blobUri, success.width, success.height)
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4ErrorResult() {
|
||||
Toast.makeText(requireContext(), R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun openGifSearch() {
|
||||
AttachmentManager.selectGif(requireActivity(), ConversationActivity.PICK_GIF, host.isMms())
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isMms(): Boolean
|
||||
fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class GifKeyboardPageViewModel : ViewModel() {
|
||||
var selectedTab: GifQuickSearchOption = GifQuickSearchOption.TRENDING
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
data class GifQuickSearch(val gifQuickSearchOption: GifQuickSearchOption, val selected: Boolean) : MappingModel<GifQuickSearch> {
|
||||
override fun areItemsTheSame(newItem: GifQuickSearch): Boolean {
|
||||
return gifQuickSearchOption == newItem.gifQuickSearchOption
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: GifQuickSearch): Boolean {
|
||||
return selected == newItem.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class GifQuickSearchAdapter(clickListener: (GifQuickSearchOption) -> Unit) : MappingAdapter() {
|
||||
init {
|
||||
registerFactory(GifQuickSearch::class.java, LayoutFactory({ v -> ViewHolder(v, clickListener) }, R.layout.keyboard_pager_category_icon))
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val listener: (GifQuickSearchOption) -> Unit) : MappingViewHolder<GifQuickSearch>(itemView) {
|
||||
private val image: ImageView = findViewById(R.id.category_icon)
|
||||
private val imageSelected: View = findViewById(R.id.category_icon_selected)
|
||||
|
||||
override fun bind(model: GifQuickSearch) {
|
||||
image.setImageResource(model.gifQuickSearchOption.image)
|
||||
image.isSelected = model.selected
|
||||
imageSelected.isSelected = model.selected
|
||||
itemView.setOnClickListener { listener(model.gifQuickSearchOption) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
enum class GifQuickSearchOption(private val rank: Int, val image: Int, val query: String) {
|
||||
TRENDING(0, R.drawable.ic_gif_trending_24, ""),
|
||||
CELEBRATE(1, R.drawable.ic_gif_celebrate_24, "celebrate"),
|
||||
LOVE(2, R.drawable.ic_gif_love_24, "love"),
|
||||
THUMBS_UP(3, R.drawable.ic_gif_thumbsup_24, "thumbs up"),
|
||||
SURPRISED(4, R.drawable.ic_gif_surprised_24, "surprised"),
|
||||
EXCITED(5, R.drawable.ic_gif_excited_24, "excited"),
|
||||
SAD(6, R.drawable.ic_gif_sad_24, "sad"),
|
||||
ANGRY(7, R.drawable.ic_gif_angry_24, "angry");
|
||||
|
||||
companion object {
|
||||
val ranked: List<GifQuickSearchOption> by lazy { values().sortedBy { it.rank } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager.widget.PagerAdapter
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardBottomTabAdapter
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider.StickerEventListener
|
||||
|
||||
class StickerKeyboardPageFragment : LoggingFragment(R.layout.keyboard_pager_sticker_page_fragment) {
|
||||
|
||||
private val presenter: StickerPresenter = StickerPresenter()
|
||||
private lateinit var provider: StickerKeyboardProvider
|
||||
|
||||
private lateinit var stickerPager: ViewPager
|
||||
private lateinit var searchView: View
|
||||
private lateinit var stickerPacksRecycler: RecyclerView
|
||||
private lateinit var manageStickers: View
|
||||
private lateinit var tabAdapter: MediaKeyboardBottomTabAdapter
|
||||
|
||||
private lateinit var viewModel: StickerKeyboardPageViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
stickerPager = view.findViewById(R.id.sticker_pager)
|
||||
searchView = view.findViewById(R.id.sticker_search)
|
||||
manageStickers = view.findViewById(R.id.sticker_manage)
|
||||
stickerPacksRecycler = view.findViewById(R.id.sticker_packs_recycler)
|
||||
|
||||
searchView.setOnClickListener { StickerSearchDialogFragment.show(requireActivity().supportFragmentManager) }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(StickerKeyboardPageViewModel::class.java)
|
||||
|
||||
tabAdapter = MediaKeyboardBottomTabAdapter(GlideApp.with(this), this::onTabSelected)
|
||||
stickerPacksRecycler.adapter = tabAdapter
|
||||
|
||||
provider = StickerKeyboardProvider(requireActivity(), findListener() ?: throw AssertionError("No sticker listener"))
|
||||
provider.requestPresentation(presenter, true)
|
||||
}
|
||||
|
||||
private fun findListener(): StickerEventListener? {
|
||||
return parentFragment as? StickerEventListener ?: requireActivity() as? StickerEventListener
|
||||
}
|
||||
|
||||
private fun onTabSelected(index: Int) {
|
||||
stickerPager.currentItem = index
|
||||
stickerPacksRecycler.smoothScrollToPosition(index)
|
||||
viewModel.selectedTab = index
|
||||
}
|
||||
|
||||
private inner class StickerPresenter : MediaKeyboardProvider.Presenter {
|
||||
override fun present(
|
||||
provider: MediaKeyboardProvider,
|
||||
pagerAdapter: PagerAdapter,
|
||||
iconProvider: MediaKeyboardProvider.TabIconProvider,
|
||||
backspaceObserver: MediaKeyboardProvider.BackspaceObserver?,
|
||||
addObserver: MediaKeyboardProvider.AddObserver?,
|
||||
searchObserver: MediaKeyboardProvider.SearchObserver?,
|
||||
startingIndex: Int
|
||||
) {
|
||||
if (stickerPager.adapter != pagerAdapter) {
|
||||
stickerPager.adapter = pagerAdapter
|
||||
}
|
||||
stickerPager.currentItem = viewModel.selectedTab
|
||||
|
||||
stickerPager.clearOnPageChangeListeners()
|
||||
stickerPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
|
||||
override fun onPageSelected(position: Int) {
|
||||
tabAdapter.setActivePosition(position)
|
||||
stickerPacksRecycler.smoothScrollToPosition(position)
|
||||
provider.setCurrentPosition(position)
|
||||
}
|
||||
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit
|
||||
override fun onPageScrollStateChanged(state: Int) = Unit
|
||||
})
|
||||
|
||||
tabAdapter.setTabIconProvider(iconProvider, pagerAdapter.count)
|
||||
tabAdapter.setActivePosition(stickerPager.currentItem)
|
||||
|
||||
manageStickers.setOnClickListener { addObserver?.onAddClicked() }
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Int {
|
||||
return stickerPager.currentItem
|
||||
}
|
||||
|
||||
override fun requestDismissal() = Unit
|
||||
override fun isVisible(): Boolean = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class StickerKeyboardPageViewModel : ViewModel() {
|
||||
var selectedTab: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Point
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.Px
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardPageAdapter
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Search dialog for finding stickers.
|
||||
*/
|
||||
class StickerSearchDialogFragment : DialogFragment(), StickerKeyboardPageAdapter.EventListener {
|
||||
|
||||
private lateinit var search: KeyboardPageSearchView
|
||||
private lateinit var list: RecyclerView
|
||||
private lateinit var noResults: View
|
||||
|
||||
private lateinit var adapter: StickerKeyboardPageAdapter
|
||||
private lateinit var layoutManager: GridLayoutManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_Animated_Bottom)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.sticker_search_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
search = view.findViewById(R.id.sticker_search_text)
|
||||
list = view.findViewById(R.id.sticker_search_list)
|
||||
noResults = view.findViewById(R.id.sticker_search_no_results)
|
||||
|
||||
adapter = StickerKeyboardPageAdapter(GlideApp.with(this), this, DeviceProperties.shouldAllowApngStickerAnimation(requireContext()))
|
||||
layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
|
||||
list.layoutManager = layoutManager
|
||||
list.adapter = adapter
|
||||
|
||||
onScreenWidthChanged(getScreenWidth())
|
||||
|
||||
val viewModel: StickerSearchViewModel = ViewModelProviders.of(this, StickerSearchViewModel.Factory(requireContext())).get(StickerSearchViewModel::class.java)
|
||||
|
||||
viewModel.searchResults.observe(viewLifecycleOwner) { stickerRecords ->
|
||||
adapter.setStickers(stickerRecords, calculateStickerSize(getScreenWidth()))
|
||||
noResults.visibility = if (stickerRecords.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
search.enableBackNavigation()
|
||||
search.callbacks = object : KeyboardPageSearchView.Callbacks {
|
||||
override fun onQueryChanged(query: String) {
|
||||
viewModel.query(query)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
ViewUtil.hideKeyboard(requireContext(), view)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
search.requestFocus()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
onScreenWidthChanged(getScreenWidth())
|
||||
}
|
||||
|
||||
private fun onScreenWidthChanged(@Px newWidth: Int) {
|
||||
layoutManager.spanCount = calculateColumnCount(newWidth)
|
||||
adapter.setStickerSize(calculateStickerSize(newWidth))
|
||||
}
|
||||
|
||||
private fun getScreenWidth(): Int {
|
||||
val size = Point()
|
||||
requireActivity().windowManager.defaultDisplay.getSize(size)
|
||||
return size.x
|
||||
}
|
||||
|
||||
private fun calculateColumnCount(@Px screenWidth: Int): Int {
|
||||
val modifier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_padding).toFloat()
|
||||
val divisor = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_divisor).toFloat()
|
||||
return ((screenWidth - modifier) / divisor).toInt()
|
||||
}
|
||||
|
||||
private fun calculateStickerSize(@Px screenWidth: Int): Int {
|
||||
val multiplier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_multiplier).toFloat()
|
||||
val columnCount = calculateColumnCount(screenWidth)
|
||||
return ((screenWidth - (columnCount + 1) * multiplier) / columnCount).toInt()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
StickerSearchDialogFragment().show(fragmentManager, "TAG")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStickerClicked(sticker: StickerRecord) {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
findListener<StickerKeyboardProvider.StickerEventListener>()?.onStickerSelected(sticker)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun onStickerLongClicked(targetView: View) = Unit
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase.StickerRecordReader
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
|
||||
private const val RECENT_LIMIT = 24
|
||||
private const val EMOJI_SEARCH_RESULTS_LIMIT = 20
|
||||
|
||||
class StickerSearchRepository(context: Context) {
|
||||
|
||||
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
|
||||
private val stickerDatabase: StickerDatabase = DatabaseFactory.getStickerDatabase(context)
|
||||
|
||||
@WorkerThread
|
||||
fun search(query: String): List<StickerRecord> {
|
||||
if (query.isEmpty()) {
|
||||
return StickerRecordReader(stickerDatabase.getRecentlyUsedStickers(RECENT_LIMIT)).readAll()
|
||||
}
|
||||
|
||||
val maybeEmojiQuery: List<StickerRecord> = findStickersForEmoji(query)
|
||||
val searchResults: List<StickerRecord> = emojiSearchDatabase.query(query, EMOJI_SEARCH_RESULTS_LIMIT)
|
||||
.map { findStickersForEmoji(it) }
|
||||
.flatten()
|
||||
|
||||
return maybeEmojiQuery + searchResults
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun findStickersForEmoji(emoji: String): List<StickerRecord> {
|
||||
val searchEmoji: String = EmojiUtil.getCanonicalRepresentation(emoji)
|
||||
|
||||
return EmojiUtil.getAllRepresentations(searchEmoji)
|
||||
.filterNotNull()
|
||||
.map { candidate -> StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate)).readAll() }
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StickerRecordReader.readAll(): List<StickerRecord> {
|
||||
val stickers: MutableList<StickerRecord> = mutableListOf()
|
||||
use { reader ->
|
||||
var record: StickerRecord? = reader.next
|
||||
while (record != null) {
|
||||
stickers.add(record)
|
||||
record = reader.next
|
||||
}
|
||||
}
|
||||
return stickers
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
|
||||
class StickerSearchViewModel(private val searchRepository: StickerSearchRepository) : ViewModel() {
|
||||
|
||||
private val searchQuery: MutableLiveData<String> = MutableLiveData("")
|
||||
|
||||
val searchResults: LiveData<List<StickerRecord>> = LiveDataUtil.mapAsync(searchQuery) { q -> searchRepository.search(q) }
|
||||
|
||||
fun query(query: String) {
|
||||
searchQuery.postValue(query)
|
||||
}
|
||||
|
||||
class Factory(context: Context) : ViewModelProvider.Factory {
|
||||
val repository = StickerSearchRepository(context)
|
||||
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(StickerSearchViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -23,6 +24,11 @@ public class EmojiValues extends SignalStoreValues {
|
||||
private static final String PREFIX = "emojiPref__";
|
||||
private static final String NEXT_SCHEDULED_CHECK = PREFIX + "next_scheduled_check";
|
||||
private static final String REACTIONS_LIST = PREFIX + "reactions_list";
|
||||
private static final String SEARCH_VERSION = PREFIX + "search_version";
|
||||
private static final String SEARCH_LANGUAGE = PREFIX + "search_language";
|
||||
private static final String LAST_SEARCH_CHECK = PREFIX + "last_search_check";
|
||||
|
||||
public static final String NO_LANGUAGE = "NO_LANGUAGE";
|
||||
|
||||
EmojiValues(@NonNull KeyValueStore store) {
|
||||
super(store);
|
||||
@@ -38,11 +44,11 @@ public class EmojiValues extends SignalStoreValues {
|
||||
return Collections.singletonList(REACTIONS_LIST);
|
||||
}
|
||||
|
||||
public long getNextScheduledCheck() {
|
||||
public long getNextScheduledImageCheck() {
|
||||
return getStore().getLong(NEXT_SCHEDULED_CHECK, 0);
|
||||
}
|
||||
|
||||
public void setNextScheduledCheck(long nextScheduledCheck) {
|
||||
public void setNextScheduledImageCheck(long nextScheduledCheck) {
|
||||
putLong(NEXT_SCHEDULED_CHECK, nextScheduledCheck);
|
||||
}
|
||||
|
||||
@@ -74,4 +80,31 @@ public class EmojiValues extends SignalStoreValues {
|
||||
public void setReactions(List<String> reactions) {
|
||||
putString(REACTIONS_LIST, Util.join(reactions, ","));
|
||||
}
|
||||
|
||||
public void onSearchIndexUpdated(int version, @NonNull String language) {
|
||||
getStore().beginWrite()
|
||||
.putInteger(SEARCH_VERSION, version)
|
||||
.putString(SEARCH_LANGUAGE, language)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public boolean hasSearchIndex() {
|
||||
return getSearchVersion() > 0 && getSearchLanguage() != null;
|
||||
}
|
||||
|
||||
public int getSearchVersion() {
|
||||
return getInteger(SEARCH_VERSION, 0);
|
||||
}
|
||||
|
||||
public @Nullable String getSearchLanguage() {
|
||||
return getString(SEARCH_LANGUAGE, null);
|
||||
}
|
||||
|
||||
public long getLastSearchIndexCheck() {
|
||||
return getLong(LAST_SEARCH_CHECK, 0);
|
||||
}
|
||||
|
||||
public void setLastSearchIndexCheck(int time) {
|
||||
putLong(LAST_SEARCH_CHECK, time);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.HudState;
|
||||
@@ -108,7 +112,10 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
|
||||
ViewTreeObserver.OnGlobalLayoutListener,
|
||||
MediaRailAdapter.RailItemListener,
|
||||
InputAwareLayout.OnKeyboardShownListener,
|
||||
InputAwareLayout.OnKeyboardHiddenListener
|
||||
InputAwareLayout.OnKeyboardHiddenListener,
|
||||
EmojiKeyboardProvider.EmojiEventListener,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiSearchFragment.Callback
|
||||
{
|
||||
private static final String TAG = Log.tag(MediaSendActivity.class);
|
||||
|
||||
@@ -987,17 +994,9 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
|
||||
|
||||
private void onEmojiToggleClicked(View v) {
|
||||
if (!emojiDrawer.resolved()) {
|
||||
emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(this, new EmojiKeyboardProvider.EmojiEventListener() {
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
getActiveInputField().dispatchKeyEvent(keyEvent);
|
||||
}
|
||||
KeyboardPagerViewModel keyboardPagerViewModel = ViewModelProviders.of(this).get(KeyboardPagerViewModel.class);
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI);
|
||||
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
getActiveInputField().insertEmoji(emoji);
|
||||
}
|
||||
}));
|
||||
emojiToggle.attach(emojiDrawer.get());
|
||||
}
|
||||
|
||||
@@ -1008,6 +1007,16 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
getActiveInputField().dispatchKeyEvent(keyEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
getActiveInputField().insertEmoji(emoji);
|
||||
}
|
||||
|
||||
private @Nullable MediaSendFragment getMediaSendFragment() {
|
||||
return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
|
||||
}
|
||||
@@ -1029,6 +1038,20 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
|
||||
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openEmojiSearch() {
|
||||
if (emojiDrawer.resolved()) {
|
||||
emojiDrawer.get().onOpenEmojiSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeEmojiSearch() {
|
||||
if (emojiDrawer.resolved()) {
|
||||
emojiDrawer.get().onCloseEmojiSearch();
|
||||
}
|
||||
}
|
||||
|
||||
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
|
||||
|
||||
int beforeLength;
|
||||
@@ -1067,7 +1090,11 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
|
||||
public void onTextChanged(CharSequence s, int start, int before,int count) {}
|
||||
|
||||
@Override
|
||||
public void onFocusChange(View v, boolean hasFocus) {}
|
||||
public void onFocusChange(View v, boolean hasFocus) {
|
||||
if (hasFocus && hud.getCurrentInput() == emojiDrawer.get()) {
|
||||
hud.showSoftkey(composeText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MentionPickerPlacer implements ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
@@ -33,7 +33,6 @@ import android.util.Pair;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -403,10 +402,9 @@ public class AttachmentManager {
|
||||
.execute();
|
||||
}
|
||||
|
||||
public static void selectGif(Activity activity, int requestCode, boolean isForMms, @ColorInt int color) {
|
||||
public static void selectGif(Activity activity, int requestCode, boolean isForMms) {
|
||||
Intent intent = new Intent(activity, GiphyActivity.class);
|
||||
intent.putExtra(GiphyActivity.EXTRA_IS_MMS, isForMms);
|
||||
intent.putExtra(GiphyActivity.EXTRA_COLOR, color);
|
||||
activity.startActivityForResult(intent, requestCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ final class ReactWithAnyEmojiAdapter extends ListAdapter<ReactWithAnyEmojiPage,
|
||||
}
|
||||
|
||||
private EmojiPageView createEmojiPageView(@NonNull Context context) {
|
||||
return new EmojiPageView(context, emojiEventListener, variationSelectorListener, true);
|
||||
return new EmojiPageView(context, emojiEventListener, variationSelectorListener, true, null);
|
||||
}
|
||||
|
||||
static abstract class ReactWithAnyEmojiPageViewHolder extends RecyclerView.ViewHolder implements ScrollableChild {
|
||||
|
||||
@@ -1,76 +1,77 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.MenuItem;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel;
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public final class ImageEditorStickerSelectActivity extends FragmentActivity {
|
||||
public final class ImageEditorStickerSelectActivity extends AppCompatActivity implements StickerKeyboardProvider.StickerEventListener, MediaKeyboard.MediaKeyboardListener {
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(@NonNull Context newBase) {
|
||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
|
||||
setContentView(R.layout.scribble_select_new_sticker_activity);
|
||||
|
||||
KeyboardPagerViewModel keyboardPagerViewModel = ViewModelProviders.of(this).get(KeyboardPagerViewModel.class);
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.STICKER);
|
||||
|
||||
MediaKeyboard mediaKeyboard = findViewById(R.id.emoji_drawer);
|
||||
|
||||
mediaKeyboard.setProviders(0, new StickerKeyboardProvider(this, new StickerKeyboardProvider.StickerEventListener() {
|
||||
@Override
|
||||
public void onStickerSelected(@NonNull StickerRecord sticker) {
|
||||
Intent intent = new Intent();
|
||||
intent.setData(sticker.getUri());
|
||||
setResult(RESULT_OK, intent);
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() ->
|
||||
DatabaseFactory.getStickerDatabase(getApplicationContext())
|
||||
.updateStickerLastUsedTime(sticker.getRowId(), System.currentTimeMillis())
|
||||
);
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerManagementClicked() {
|
||||
startActivity(StickerManagementActivity.getIntent(ImageEditorStickerSelectActivity.this));
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
mediaKeyboard.setKeyboardListener(new MediaKeyboard.MediaKeyboardListener() {
|
||||
@Override
|
||||
public void onShown() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) {
|
||||
}
|
||||
});
|
||||
|
||||
mediaKeyboard.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShown() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardChanged(@NonNull KeyboardPage page) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerSelected(@NonNull StickerRecord sticker) {
|
||||
Intent intent = new Intent();
|
||||
intent.setData(sticker.getUri());
|
||||
setResult(RESULT_OK, intent);
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getStickerDatabase(getApplicationContext())
|
||||
.updateStickerLastUsedTime(sticker.getRowId(), System.currentTimeMillis()));
|
||||
ViewUtil.hideKeyboard(this, findViewById(android.R.id.content));
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerManagementClicked() {
|
||||
startActivity(StickerManagementActivity.getIntent(ImageEditorStickerSelectActivity.this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.FtsUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -56,23 +57,6 @@ public class SearchRepository {
|
||||
|
||||
private static final String TAG = Log.tag(SearchRepository.class);
|
||||
|
||||
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
|
||||
static {
|
||||
// Several ranges of invalid ASCII characters
|
||||
for (int i = 33; i <= 47; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 58; i <= 64; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 91; i <= 96; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 123; i <= 126; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
private final SearchDatabase searchDatabase;
|
||||
private final ContactRepository contactRepository;
|
||||
@@ -104,7 +88,7 @@ public class SearchRepository {
|
||||
}
|
||||
|
||||
serialExecutor.execute(() -> {
|
||||
String cleanQuery = sanitizeQuery(query);
|
||||
String cleanQuery = FtsUtil.sanitize(query);
|
||||
|
||||
Future<List<Recipient>> contacts = parallelExecutor.submit(() -> queryContacts(cleanQuery));
|
||||
Future<List<ThreadRecord>> conversations = parallelExecutor.submit(() -> queryConversations(cleanQuery));
|
||||
@@ -133,7 +117,7 @@ public class SearchRepository {
|
||||
|
||||
serialExecutor.execute(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
List<MessageResult> messages = queryMessages(sanitizeQuery(query), threadId);
|
||||
List<MessageResult> messages = queryMessages(FtsUtil.sanitize(query), threadId);
|
||||
List<MessageResult> mentionMessages = queryMentions(sanitizeQueryAsTokens(query), threadId);
|
||||
|
||||
Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -346,35 +330,13 @@ public class SearchRepository {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes.
|
||||
* MATCH queries have a separate format of their own that disallow most "special" characters.
|
||||
*
|
||||
* Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm".
|
||||
* However, if we replace the apostrophe with a space, then the query will find the match.
|
||||
*/
|
||||
private String sanitizeQuery(@NonNull String query) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < query.length(); i++) {
|
||||
char c = query.charAt(i);
|
||||
if (!BANNED_CHARACTERS.contains(c)) {
|
||||
out.append(c);
|
||||
} else if (c == '\'') {
|
||||
out.append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private @NonNull List<String> sanitizeQueryAsTokens(@NonNull String query) {
|
||||
String[] parts = query.split("\\s+");
|
||||
if (parts.length > 3) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Stream.of(parts).map(this::sanitizeQuery).toList();
|
||||
return Stream.of(parts).map(FtsUtil::sanitize).toList();
|
||||
}
|
||||
|
||||
private static @NonNull List<MessageResult> mergeMessagesAndMentions(@NonNull List<MessageResult> messages, @NonNull List<MessageResult> mentionMessages) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.List;
|
||||
* Adapter for a specific page in the sticker keyboard. Shows the stickers in a grid.
|
||||
* @see StickerKeyboardPageFragment
|
||||
*/
|
||||
final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeyboardPageAdapter.StickerKeyboardPageViewHolder> {
|
||||
public final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeyboardPageAdapter.StickerKeyboardPageViewHolder> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
@@ -34,7 +34,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeybo
|
||||
|
||||
private int stickerSize;
|
||||
|
||||
StickerKeyboardPageAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean allowApngAnimation) {
|
||||
public StickerKeyboardPageAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean allowApngAnimation) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
this.allowApngAnimation = allowApngAnimation;
|
||||
@@ -68,7 +68,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeybo
|
||||
return stickers.size();
|
||||
}
|
||||
|
||||
void setStickers(@NonNull List<StickerRecord> stickers, @Px int stickerSize) {
|
||||
public void setStickers(@NonNull List<StickerRecord> stickers, @Px int stickerSize) {
|
||||
this.stickers.clear();
|
||||
this.stickers.addAll(stickers);
|
||||
|
||||
@@ -77,7 +77,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeybo
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
void setStickerSize(@Px int stickerSize) {
|
||||
public void setStickerSize(@Px int stickerSize) {
|
||||
this.stickerSize = stickerSize;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
@@ -131,7 +131,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter<StickerKeybo
|
||||
}
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
public interface EventListener {
|
||||
void onStickerClicked(@NonNull StickerRecord sticker);
|
||||
void onStickerLongClicked(@NonNull View targetView);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.database.DatabaseUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class FtsUtil {
|
||||
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
|
||||
static {
|
||||
// Several ranges of invalid ASCII characters
|
||||
for (int i = 33; i <= 47; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 58; i <= 64; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 91; i <= 96; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 123; i <= 126; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
}
|
||||
|
||||
private FtsUtil() {}
|
||||
|
||||
/**
|
||||
* Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes.
|
||||
* MATCH queries have a separate format of their own that disallow most "special" characters.
|
||||
*
|
||||
* Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm".
|
||||
* However, if we replace the apostrophe with a space, then the query will find the match.
|
||||
*/
|
||||
public static @NonNull String sanitize(@NonNull String query) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < query.length(); i++) {
|
||||
char c = query.charAt(i);
|
||||
if (!BANNED_CHARACTERS.contains(c)) {
|
||||
out.append(c);
|
||||
} else if (c == '\'') {
|
||||
out.append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the string (via {@link #sanitize(String)}) and appends * at the right spots such that each token in the query will be treated as a prefix.
|
||||
*/
|
||||
public static @NonNull String createPrefixMatchString(@NonNull String query) {
|
||||
query = FtsUtil.sanitize(query);
|
||||
|
||||
return Stream.of(query.split(" "))
|
||||
.map(String::trim)
|
||||
.filter(s -> s.length() > 0)
|
||||
.map(FtsUtil::fixQuotes)
|
||||
.collect(StringBuilder::new, (sb, s) -> sb.append(s).append("* "))
|
||||
.toString();
|
||||
}
|
||||
|
||||
private static String fixQuotes(String s) {
|
||||
return "\"" + s.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -1292,6 +1292,6 @@ public class TextSecurePreferences {
|
||||
|
||||
// NEVER rename these -- they're persisted by name
|
||||
public enum MediaKeyboardMode {
|
||||
EMOJI, STICKER
|
||||
EMOJI, STICKER, GIF
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.view.View
|
||||
|
||||
var View.visible: Boolean
|
||||
get() {
|
||||
return this.visibility == View.VISIBLE
|
||||
}
|
||||
|
||||
set(value) {
|
||||
this.visibility = if (value) View.VISIBLE else View.GONE
|
||||
}
|
||||
Reference in New Issue
Block a user