diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f56143f9fd..c71c2d9e07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -492,7 +492,7 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> 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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java index 142ff5cbff..dae12efa93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index dfcd561c6e..ce000529c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -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))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java index c43560ce6f..1870ec7a7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java index 5fe7ba6d73..5971f2198b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java @@ -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(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java index 69d79361fb..f9d8106323 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java @@ -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 implements PopupWindow.OnDismissListener { - - private final List 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 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 { + @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 { + public SearchViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + public void bind(@NonNull @NotNull SearchModel model) { + } + } + + static class EmojiModel implements MappingModel { + + 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 { + + 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java index b0787e0bcc..3a01ff6c04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java @@ -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; + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java index d632deceba..7127b9488b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java index 92176b3207..b2f5f962b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java @@ -16,22 +16,19 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter eventListener.onTabSelected(index)); } @@ -98,7 +87,7 @@ public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt index 4411e1e925..d976f7283b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 0b43c8035b..1b08c5fc6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt index 6761593c98..b37683e67f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index f23312f30c..5295f27d1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java new file mode 100644 index 0000000000..5afbaf463b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java @@ -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 query(@NonNull String query, int limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String matchString = FtsUtil.createPrefixMatchString(query); + List 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 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(); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 59c58ded0c..45a7371eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/EmojiSearchData.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/EmojiSearchData.java new file mode 100644 index 0000000000..ad5cfe94bf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/EmojiSearchData.java @@ -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 tags; + + public EmojiSearchData() {} + + public @NonNull String getEmoji() { + return emoji; + } + + public @NonNull List getTags() { + return tags; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt index f05e07812b..530853930a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt index c790feed43..a899488ed1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java index daabada97f..5a0c80d602 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Fragment.java @@ -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)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java index 5a684ef0cc..c2935f8a1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewModel.java index 9aa0142955..879ef3173d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewModel.java @@ -52,6 +52,10 @@ public final class GiphyMp4ViewModel extends ViewModel { .toList())); } + LiveData> getPagedData() { + return pagedData; + } + public void updateSearchQuery(@Nullable String query) { if (!Objects.equals(query, this.query)) { this.query = query; diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index 185e6000ff..ed76d432b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -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() {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java index f2682cfa12..cba3a8dae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java @@ -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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/EmojiSearchIndexDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/EmojiSearchIndexDownloadJob.java new file mode 100644 index 0000000000..6ef575cffb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/EmojiSearchIndexDownloadJob.java @@ -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 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 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 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 languages; + + public Manifest() {} + + public int getVersion() { + return version; + } + + public @NonNull List getLanguages() { + return languages != null ? languages : Collections.emptyList(); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull EmojiSearchIndexDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new EmojiSearchIndexDownloadJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 37f5f4016b..bbc3f61572 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPage.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPage.kt new file mode 100644 index 0000000000..a709490f64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPage.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.keyboard + +enum class KeyboardPage { + EMOJI, + STICKER, + GIF +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPageCategoryIconViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPageCategoryIconViewHolder.kt new file mode 100644 index 0000000000..fa559012f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPageCategoryIconViewHolder.kt @@ -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> : MappingModel { + val key: String + val selected: Boolean + + fun getIcon(context: Context): Drawable +} + +class KeyboardPageCategoryIconViewHolder>(itemView: View, private val onPageSelected: (String) -> Unit) : MappingViewHolder(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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt new file mode 100644 index 0000000000..a972a6b070 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt @@ -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, 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()?.onKeyboardChanged(page) + } + + private fun displayEmojiPage() = displayPage(::EmojiKeyboardPageFragment) + + private fun displayGifPage() = displayPage(::GifKeyboardPageFragment) + + private fun displayStickerPage() = displayPage(::StickerKeyboardPageFragment) + + private inline fun 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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt new file mode 100644 index 0000000000..230125043b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt @@ -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 + private val pages: DefaultValueLiveData> + + init { + val startingPages: MutableSet = 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 = page + fun pages(): LiveData> = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/ListenerExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/ListenerExtensions.kt new file mode 100644 index 0000000000..74dffa0126 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/ListenerExtensions.kt @@ -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 Fragment.findListener(): T? { + var parent: Fragment? = parentFragment + while (parent != null) { + if (parent is T) { + return parent + } + parent = parent.parentFragment + } + + return requireActivity() as? T +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageAdapter.kt new file mode 100644 index 0000000000..4c3c2fc6f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageAdapter.kt @@ -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(emojiPageView) { + + override fun bind(model: EmojiPageMappingModel) { + emojiPageView.bindSearchableAdapter(model.emojiPageModel) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoriesAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoriesAdapter.kt new file mode 100644 index 0000000000..3d0f23918d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoriesAdapter.kt @@ -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(v, onPageSelected) }, R.layout.keyboard_pager_category_icon)) + registerFactory(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder(v, onPageSelected) }, R.layout.keyboard_pager_category_icon)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoryMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoryMappingModel.kt new file mode 100644 index 0000000000..5b7c105ffa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageCategoryMappingModel.kt @@ -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 { + + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt new file mode 100644 index 0000000000..cef4f4fd7e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageViewModel.kt new file mode 100644 index 0000000000..26db7dcb10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageViewModel.kt @@ -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(getStartingTab()) + + val selectedKey: LiveData + get() = internalSelectedKey + + val categories: LiveData = 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 = 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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiPageMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiPageMappingModel.kt new file mode 100644 index 0000000000..f9ac6bc22b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiPageMappingModel.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt new file mode 100644 index 0000000000..df3308685e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt new file mode 100644 index 0000000000..62ee3e9ea7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt new file mode 100644 index 0000000000..0027af24f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt @@ -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 = emojiSearchDatabase.query(query, EMOJI_SEARCH_LIMIT) + + val variationMap: Map = EmojiSource.latest.variationMap + val emojiVariationSets: MutableMap> = mutableMapOf() + + variationMap + .filterKeys { emoji.contains(it) } + .forEach { (variation, canonical) -> + val set: LinkedHashSet = emojiVariationSets.getOrDefault(canonical, linkedSetOf()) + + set.add(variation) + emojiVariationSets[canonical] = set + } + + val displayEmoji: List = emoji.map { canonical -> + val variationSet: LinkedHashSet = linkedSetOf(canonical).apply { + addAll(emojiVariationSets.getOrDefault(canonical, linkedSetOf())) + } + + Emoji(variationSet.toList()) + } + + consumer(EmojiSearchResultsPageModel(emoji, displayEmoji)) + } + } + } + + private class EmojiSearchResultsPageModel( + private val emoji: List, + private val displayEmoji: List + ) : EmojiPageModel { + override fun getIconAttr(): Int = -1 + + override fun getEmoji(): List = emoji + + override fun getDisplayEmoji(): List = displayEmoji + + override fun getSpriteUri(): Uri? = null + + override fun isDynamic(): Boolean = false + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchViewModel.kt new file mode 100644 index 0000000000..ff39708ca9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchViewModel.kt @@ -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() + + val pageModel: LiveData = internalPageModel + + init { + onQueryChanged("") + } + + fun onQueryChanged(query: String) { + repository.submitQuery(query, internalPageModel::postValue) + } + + class Factory(private val repository: EmojiSearchRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(EmojiSearchViewModel(repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageFragment.kt new file mode 100644 index 0000000000..e0e9536267 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageFragment.kt @@ -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() ?: 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(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 = 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageViewModel.kt new file mode 100644 index 0000000000..4a74b02e69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifKeyboardPageViewModel.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.keyboard.gif + +import androidx.lifecycle.ViewModel + +class GifKeyboardPageViewModel : ViewModel() { + var selectedTab: GifQuickSearchOption = GifQuickSearchOption.TRENDING +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearch.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearch.kt new file mode 100644 index 0000000000..30b18e7f76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearch.kt @@ -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 { + override fun areItemsTheSame(newItem: GifQuickSearch): Boolean { + return gifQuickSearchOption == newItem.gifQuickSearchOption + } + + override fun areContentsTheSame(newItem: GifQuickSearch): Boolean { + return selected == newItem.selected + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchAdapter.kt new file mode 100644 index 0000000000..cbae8db66f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchAdapter.kt @@ -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(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) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchOption.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchOption.kt new file mode 100644 index 0000000000..67f2cae418 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/gif/GifQuickSearchOption.kt @@ -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 by lazy { values().sortedBy { it.rank } } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageFragment.kt new file mode 100644 index 0000000000..91e4b0b589 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageFragment.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageViewModel.kt new file mode 100644 index 0000000000..d2b4e25d9d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerKeyboardPageViewModel.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.keyboard.sticker + +import androidx.lifecycle.ViewModel + +class StickerKeyboardPageViewModel : ViewModel() { + var selectedTab: Int = 0 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt new file mode 100644 index 0000000000..10a01dc66e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt @@ -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()?.onStickerSelected(sticker) + dismissAllowingStateLoss() + } + + override fun onStickerLongClicked(targetView: View) = Unit +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt new file mode 100644 index 0000000000..db4b233cf5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt @@ -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 { + if (query.isEmpty()) { + return StickerRecordReader(stickerDatabase.getRecentlyUsedStickers(RECENT_LIMIT)).readAll() + } + + val maybeEmojiQuery: List = findStickersForEmoji(query) + val searchResults: List = emojiSearchDatabase.query(query, EMOJI_SEARCH_RESULTS_LIMIT) + .map { findStickersForEmoji(it) } + .flatten() + + return maybeEmojiQuery + searchResults + } + + @WorkerThread + private fun findStickersForEmoji(emoji: String): List { + val searchEmoji: String = EmojiUtil.getCanonicalRepresentation(emoji) + + return EmojiUtil.getAllRepresentations(searchEmoji) + .filterNotNull() + .map { candidate -> StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate)).readAll() } + .flatten() + } +} + +private fun StickerRecordReader.readAll(): List { + val stickers: MutableList = mutableListOf() + use { reader -> + var record: StickerRecord? = reader.next + while (record != null) { + stickers.add(record) + record = reader.next + } + } + return stickers +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchViewModel.kt new file mode 100644 index 0000000000..29e4661d88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchViewModel.kt @@ -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 = MutableLiveData("") + + val searchResults: LiveData> = 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 create(modelClass: Class): T { + return requireNotNull(modelClass.cast(StickerSearchViewModel(repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java index 0b8b93a89c..232f419689 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java @@ -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 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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 4abc74c56d..61ecece9cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index dc0c5bc524..c34449a042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java index 2343f98235..0dcff4c3cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java @@ -90,7 +90,7 @@ final class ReactWithAnyEmojiAdapter extends ListAdapter - 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 36aff425d1..57e6cbfae4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -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 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> contacts = parallelExecutor.submit(() -> queryContacts(cleanQuery)); Future> conversations = parallelExecutor.submit(() -> queryConversations(cleanQuery)); @@ -133,7 +117,7 @@ public class SearchRepository { serialExecutor.execute(() -> { long startTime = System.currentTimeMillis(); - List messages = queryMessages(sanitizeQuery(query), threadId); + List messages = queryMessages(FtsUtil.sanitize(query), threadId); List 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 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 mergeMessagesAndMentions(@NonNull List messages, @NonNull List mentionMessages) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java index f7473b7a83..b14fa7b93a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java @@ -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 { +public final class StickerKeyboardPageAdapter extends RecyclerView.Adapter { private final GlideRequests glideRequests; private final EventListener eventListener; @@ -34,7 +34,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter stickers, @Px int stickerSize) { + public void setStickers(@NonNull List stickers, @Px int stickerSize) { this.stickers.clear(); this.stickers.addAll(stickers); @@ -77,7 +77,7 @@ final class StickerKeyboardPageAdapter extends RecyclerView.Adapter 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("\"", "\"\"") + "\""; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 9d9b79638d..e24d33349e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -1292,6 +1292,6 @@ public class TextSecurePreferences { // NEVER rename these -- they're persisted by name public enum MediaKeyboardMode { - EMOJI, STICKER + EMOJI, STICKER, GIF } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt new file mode 100644 index 0000000000..517c160b2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -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 + } diff --git a/app/src/main/res/anim/slide_fade_from_bottom.xml b/app/src/main/res/anim/slide_fade_from_bottom.xml new file mode 100644 index 0000000000..4d1bdf9a49 --- /dev/null +++ b/app/src/main/res/anim/slide_fade_from_bottom.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_fade_to_bottom.xml b/app/src/main/res/anim/slide_fade_to_bottom.xml new file mode 100644 index 0000000000..5deb15083d --- /dev/null +++ b/app/src/main/res/anim/slide_fade_to_bottom.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl/search_bar_end.xml b/app/src/main/res/drawable-ldrtl/search_bar_end.xml new file mode 100644 index 0000000000..8ee0f611ca --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/search_bar_end.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl/search_bar_start.xml b/app/src/main/res/drawable-ldrtl/search_bar_start.xml new file mode 100644 index 0000000000..cf31e32384 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/search_bar_start.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_backspace_24.xml b/app/src/main/res/drawable-night/ic_backspace_24.xml new file mode 100644 index 0000000000..c5216b1402 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_backspace_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji.xml b/app/src/main/res/drawable-night/ic_emoji.xml index ed160bb6d5..e6e3acabd9 100644 --- a/app/src/main/res/drawable-night/ic_emoji.xml +++ b/app/src/main/res/drawable-night/ic_emoji.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable-night/ic_gif_angry_24.xml b/app/src/main/res/drawable-night/ic_gif_angry_24.xml new file mode 100644 index 0000000000..7a7e4d39bc --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_angry_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_gif_celebrate_24.xml b/app/src/main/res/drawable-night/ic_gif_celebrate_24.xml new file mode 100644 index 0000000000..51e0069608 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_celebrate_24.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_gif_excited_24.xml b/app/src/main/res/drawable-night/ic_gif_excited_24.xml new file mode 100644 index 0000000000..9473ecf313 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_excited_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_gif_love_24.xml b/app/src/main/res/drawable-night/ic_gif_love_24.xml new file mode 100644 index 0000000000..5ecc9ecee9 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_love_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_gif_sad_24.xml b/app/src/main/res/drawable-night/ic_gif_sad_24.xml new file mode 100644 index 0000000000..42c9d91ba3 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_sad_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_gif_surprised_24.xml b/app/src/main/res/drawable-night/ic_gif_surprised_24.xml new file mode 100644 index 0000000000..3c4c882d30 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_surprised_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/ic_gif_thumbsup_24.xml b/app/src/main/res/drawable-night/ic_gif_thumbsup_24.xml new file mode 100644 index 0000000000..0eeac4c252 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_thumbsup_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_gif_trending_24.xml b/app/src/main/res/drawable-night/ic_gif_trending_24.xml new file mode 100644 index 0000000000..d23204f1b5 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gif_trending_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_sticker_24.xml b/app/src/main/res/drawable-night/ic_sticker_24.xml index 6c2e76a671..93a93a4a10 100644 --- a/app/src/main/res/drawable-night/ic_sticker_24.xml +++ b/app/src/main/res/drawable-night/ic_sticker_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_backspace_24.xml b/app/src/main/res/drawable/ic_backspace_24.xml new file mode 100644 index 0000000000..8615a71b28 --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji.xml b/app/src/main/res/drawable/ic_emoji.xml index 943277e7a3..726a0969ee 100644 --- a/app/src/main/res/drawable/ic_emoji.xml +++ b/app/src/main/res/drawable/ic_emoji.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_gif_angry_24.xml b/app/src/main/res/drawable/ic_gif_angry_24.xml new file mode 100644 index 0000000000..f4eac9e3d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_angry_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_gif_celebrate_24.xml b/app/src/main/res/drawable/ic_gif_celebrate_24.xml new file mode 100644 index 0000000000..c34765110d --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_celebrate_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_gif_excited_24.xml b/app/src/main/res/drawable/ic_gif_excited_24.xml new file mode 100644 index 0000000000..7fbd9c490c --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_excited_24.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_gif_love_24.xml b/app/src/main/res/drawable/ic_gif_love_24.xml new file mode 100644 index 0000000000..c628a9520c --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_love_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_gif_sad_24.xml b/app/src/main/res/drawable/ic_gif_sad_24.xml new file mode 100644 index 0000000000..45578262c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_sad_24.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_gif_surprised_24.xml b/app/src/main/res/drawable/ic_gif_surprised_24.xml new file mode 100644 index 0000000000..d681deb4f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_surprised_24.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_gif_thumbsup_24.xml b/app/src/main/res/drawable/ic_gif_thumbsup_24.xml new file mode 100644 index 0000000000..4c7076e776 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_thumbsup_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_gif_trending_24.xml b/app/src/main/res/drawable/ic_gif_trending_24.xml new file mode 100644 index 0000000000..1b7e9f2264 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_trending_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sticker_24.xml b/app/src/main/res/drawable/ic_sticker_24.xml index 94189573f9..5612e6fda4 100644 --- a/app/src/main/res/drawable/ic_sticker_24.xml +++ b/app/src/main/res/drawable/ic_sticker_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/keyboard_pager_fragment_category_selected.xml b/app/src/main/res/drawable/keyboard_pager_fragment_category_selected.xml new file mode 100644 index 0000000000..0cbd7f68a7 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_pager_fragment_category_selected.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/keyboard_pager_fragment_emoji_icon.xml b/app/src/main/res/drawable/keyboard_pager_fragment_emoji_icon.xml new file mode 100644 index 0000000000..f2a6adffce --- /dev/null +++ b/app/src/main/res/drawable/keyboard_pager_fragment_emoji_icon.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/keyboard_pager_fragment_gif_icon.xml b/app/src/main/res/drawable/keyboard_pager_fragment_gif_icon.xml new file mode 100644 index 0000000000..dc2a216682 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_pager_fragment_gif_icon.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/keyboard_pager_fragment_sticker_icon.xml b/app/src/main/res/drawable/keyboard_pager_fragment_sticker_icon.xml new file mode 100644 index 0000000000..6b56c88055 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_pager_fragment_sticker_icon.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_bar_end.xml b/app/src/main/res/drawable/search_bar_end.xml new file mode 100644 index 0000000000..cf31e32384 --- /dev/null +++ b/app/src/main/res/drawable/search_bar_end.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_bar_start.xml b/app/src/main/res/drawable/search_bar_start.xml new file mode 100644 index 0000000000..8ee0f611ca --- /dev/null +++ b/app/src/main/res/drawable/search_bar_start.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_page_view_search.xml b/app/src/main/res/layout/emoji_page_view_search.xml new file mode 100644 index 0000000000..654bc22fa4 --- /dev/null +++ b/app/src/main/res/layout/emoji_page_view_search.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_search_fragment.xml b/app/src/main/res/layout/emoji_search_fragment.xml new file mode 100644 index 0000000000..e01d28cf58 --- /dev/null +++ b/app/src/main/res/layout/emoji_search_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_search_result_display_item.xml b/app/src/main/res/layout/emoji_search_result_display_item.xml new file mode 100644 index 0000000000..e2b967cf42 --- /dev/null +++ b/app/src/main/res/layout/emoji_search_result_display_item.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gif_keyboard_page_fragment.xml b/app/src/main/res/layout/gif_keyboard_page_fragment.xml new file mode 100644 index 0000000000..1f2e3ac08a --- /dev/null +++ b/app/src/main/res/layout/gif_keyboard_page_fragment.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/giphy_activity.xml b/app/src/main/res/layout/giphy_activity.xml index b3cf06abad..0e54a4d28f 100644 --- a/app/src/main/res/layout/giphy_activity.xml +++ b/app/src/main/res/layout/giphy_activity.xml @@ -2,8 +2,7 @@ + android:layout_height="match_parent"> + android:background="@color/signal_background_primary"> - + android:background="@color/signal_background_primary" + android:minHeight="?attr/actionBarSize" + app:layout_scrollFlags="scroll|enterAlways"> + + + + diff --git a/app/src/main/res/layout/giphy_mp4.xml b/app/src/main/res/layout/giphy_mp4.xml index dd985c0f65..226483d65f 100644 --- a/app/src/main/res/layout/giphy_mp4.xml +++ b/app/src/main/res/layout/giphy_mp4.xml @@ -1,13 +1,21 @@ - + android:layout_height="wrap_content"> - + android:layout_height="0dp" + android:layout_margin="3dp"> - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/giphy_mp4_fragment.xml b/app/src/main/res/layout/giphy_mp4_fragment.xml index 9bd80225bd..198e362954 100644 --- a/app/src/main/res/layout/giphy_mp4_fragment.xml +++ b/app/src/main/res/layout/giphy_mp4_fragment.xml @@ -33,7 +33,9 @@ android:id="@+id/giphy_recycler" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" android:orientation="vertical" + android:padding="5dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/giphy_mp4" /> diff --git a/app/src/main/res/layout/keyboard_pager_category_icon.xml b/app/src/main/res/layout/keyboard_pager_category_icon.xml new file mode 100644 index 0000000000..e9183e073e --- /dev/null +++ b/app/src/main/res/layout/keyboard_pager_category_icon.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml b/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml new file mode 100644 index 0000000000..cc3ffa9732 --- /dev/null +++ b/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_pager_fragment.xml b/app/src/main/res/layout/keyboard_pager_fragment.xml new file mode 100644 index 0000000000..f8925985cd --- /dev/null +++ b/app/src/main/res/layout/keyboard_pager_fragment.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_pager_search_bar.xml b/app/src/main/res/layout/keyboard_pager_search_bar.xml new file mode 100644 index 0000000000..59ccfd9415 --- /dev/null +++ b/app/src/main/res/layout/keyboard_pager_search_bar.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_pager_sticker_page_fragment.xml b/app/src/main/res/layout/keyboard_pager_sticker_page_fragment.xml new file mode 100644 index 0000000000..54b07db131 --- /dev/null +++ b/app/src/main/res/layout/keyboard_pager_sticker_page_fragment.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/media_keyboard.xml b/app/src/main/res/layout/media_keyboard.xml index c3d721c254..d05ff919e3 100644 --- a/app/src/main/res/layout/media_keyboard.xml +++ b/app/src/main/res/layout/media_keyboard.xml @@ -1,122 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml b/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml index d54055a4b9..7baaf1c73e 100644 --- a/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml +++ b/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml @@ -1,33 +1,22 @@ - + android:background="?selectableItemBackgroundBorderless"> + android:id="@+id/category_icon_selected" + android:layout_width="48dp" + android:layout_height="48dp" + android:background="@drawable/keyboard_pager_fragment_category_selected" /> - - - - - + + diff --git a/app/src/main/res/layout/react_with_any_emoji_tab.xml b/app/src/main/res/layout/react_with_any_emoji_tab.xml index 50e1792790..d4e9fcaacb 100644 --- a/app/src/main/res/layout/react_with_any_emoji_tab.xml +++ b/app/src/main/res/layout/react_with_any_emoji_tab.xml @@ -1,11 +1,18 @@ - + android:layout_margin="8dp"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/react_with_any_emoji_tabs.xml b/app/src/main/res/layout/react_with_any_emoji_tabs.xml index 1bd5496592..ea90a41f27 100644 --- a/app/src/main/res/layout/react_with_any_emoji_tabs.xml +++ b/app/src/main/res/layout/react_with_any_emoji_tabs.xml @@ -1,5 +1,5 @@ - + android:layout_gravity="top" + android:background="@color/signal_inverse_transparent_05" /> - + - + - + - + - + + + + diff --git a/app/src/main/res/layout/sticker_keyboard_page.xml b/app/src/main/res/layout/sticker_keyboard_page.xml index ddfee4d855..870b54bbe3 100644 --- a/app/src/main/res/layout/sticker_keyboard_page.xml +++ b/app/src/main/res/layout/sticker_keyboard_page.xml @@ -1,8 +1,10 @@ - + android:paddingEnd="4dp" + android:scrollIndicators="top|bottom" + tools:ignore="UnusedAttribute" /> diff --git a/app/src/main/res/layout/sticker_keyboard_page_list_item.xml b/app/src/main/res/layout/sticker_keyboard_page_list_item.xml index 379a309777..40882fba07 100644 --- a/app/src/main/res/layout/sticker_keyboard_page_list_item.xml +++ b/app/src/main/res/layout/sticker_keyboard_page_list_item.xml @@ -1,6 +1,8 @@ - + android:layout_height="wrap_content" + android:background="?selectableItemBackground" + tools:srcCompat="@tools:sample/avatars" /> diff --git a/app/src/main/res/layout/sticker_search_dialog_fragment.xml b/app/src/main/res/layout/sticker_search_dialog_fragment.xml new file mode 100644 index 0000000000..f9679709cd --- /dev/null +++ b/app/src/main/res/layout/sticker_search_dialog_fragment.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 3a93f917a5..3f2daaf851 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -26,8 +26,8 @@ @color/core_grey_15 @color/core_grey_25 - @color/core_grey_10 - @color/core_grey_45 + @color/core_white + @color/core_grey_15 @color/core_ultramarine_light @color/transparent_black_15 @@ -141,4 +141,5 @@ @color/core_grey_80 @color/core_grey_80 + @color/transparent_white_10 diff --git a/app/src/main/res/values/animations.xml b/app/src/main/res/values/animations.xml index b5137d00f5..2b0cb09d55 100644 --- a/app/src/main/res/values/animations.xml +++ b/app/src/main/res/values/animations.xml @@ -5,6 +5,11 @@ @anim/slide_to_top + + + +