Add Emoji Search, Sticker Search, and GIF Keyboard.

Co-authored-by: Alex Hart <alex@signal.org>
Co-authored-by: Cody Henthorne <cody@signal.org>
Co-authored-by: ⁨Greyson Parrelli<greyson@signal.org>
This commit is contained in:
Android Team
2021-05-26 10:47:14 -03:00
committed by Cody Henthorne
parent 66c3b1388a
commit 08e86b8c82
119 changed files with 3545 additions and 721 deletions

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.keyboard
enum class KeyboardPage {
EMOJI,
STICKER,
GIF
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.keyboard
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
interface KeyboardPageCategoryIconMappingModel<T : KeyboardPageCategoryIconMappingModel<T>> : MappingModel<T> {
val key: String
val selected: Boolean
fun getIcon(context: Context): Drawable
}
class KeyboardPageCategoryIconViewHolder<T : KeyboardPageCategoryIconMappingModel<T>>(itemView: View, private val onPageSelected: (String) -> Unit) : MappingViewHolder<T>(itemView) {
private val iconView: AppCompatImageView = itemView.findViewById(R.id.category_icon)
private val iconSelected: View = itemView.findViewById(R.id.category_icon_selected)
override fun bind(model: T) {
itemView.setOnClickListener {
onPageSelected(model.key)
}
iconView.setImageDrawable(model.getIcon(context))
iconView.isSelected = model.selected
iconSelected.isSelected = model.selected
}
}

View File

@@ -0,0 +1,107 @@
package org.thoughtcrime.securesms.keyboard
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment
import org.thoughtcrime.securesms.util.visible
import kotlin.reflect.KClass
class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) {
private lateinit var emojiButton: View
private lateinit var stickerButton: View
private lateinit var gifButton: View
private lateinit var viewModel: KeyboardPagerViewModel
private val fragments: MutableMap<KClass<*>, Fragment> = mutableMapOf()
private var currentFragment: Fragment? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
emojiButton = view.findViewById(R.id.keyboard_pager_fragment_emoji)
stickerButton = view.findViewById(R.id.keyboard_pager_fragment_sticker)
gifButton = view.findViewById(R.id.keyboard_pager_fragment_gif)
viewModel = ViewModelProviders.of(requireActivity())[KeyboardPagerViewModel::class.java]
viewModel.page().observe(viewLifecycleOwner, this::onPageSelected)
viewModel.pages().observe(viewLifecycleOwner) { pages ->
emojiButton.visible = pages.contains(KeyboardPage.EMOJI) && pages.size > 1
stickerButton.visible = pages.contains(KeyboardPage.STICKER) && pages.size > 1
gifButton.visible = pages.contains(KeyboardPage.GIF) && pages.size > 1
}
emojiButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.EMOJI) }
stickerButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.STICKER) }
gifButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.GIF) }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.page().value?.let(this::onPageSelected)
}
private fun onPageSelected(page: KeyboardPage) {
emojiButton.isSelected = page == KeyboardPage.EMOJI
stickerButton.isSelected = page == KeyboardPage.STICKER
gifButton.isSelected = page == KeyboardPage.GIF
when (page) {
KeyboardPage.EMOJI -> displayEmojiPage()
KeyboardPage.GIF -> displayGifPage()
KeyboardPage.STICKER -> displayStickerPage()
}
findListener<MediaKeyboard.MediaKeyboardListener>()?.onKeyboardChanged(page)
}
private fun displayEmojiPage() = displayPage(::EmojiKeyboardPageFragment)
private fun displayGifPage() = displayPage(::GifKeyboardPageFragment)
private fun displayStickerPage() = displayPage(::StickerKeyboardPageFragment)
private inline fun <reified F : Fragment> displayPage(fragmentFactory: () -> F) {
if (currentFragment is F) {
return
}
val transaction = childFragmentManager.beginTransaction()
currentFragment?.let { transaction.hide(it) }
var fragment = fragments[F::class]
if (fragment == null) {
fragment = fragmentFactory()
transaction.add(R.id.fragment_container, fragment)
fragments[F::class] = fragment
} else {
transaction.show(fragment)
}
currentFragment = fragment
transaction.commitAllowingStateLoss()
}
fun show() {
if (isAdded && view != null) {
viewModel.page().value?.let(this::onPageSelected)
}
}
fun hide() {
if (isAdded && view != null) {
val transaction = childFragmentManager.beginTransaction()
fragments.values.forEach { transaction.remove(it) }
transaction.commitAllowingStateLoss()
currentFragment = null
fragments.clear()
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.keyboard
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stickers.StickerSearchRepository
import org.thoughtcrime.securesms.util.DefaultValueLiveData
class KeyboardPagerViewModel : ViewModel() {
private val page: DefaultValueLiveData<KeyboardPage>
private val pages: DefaultValueLiveData<Set<KeyboardPage>>
init {
val startingPages: MutableSet<KeyboardPage> = KeyboardPage.values().toMutableSet()
if (SignalStore.settings().isPreferSystemEmoji) {
startingPages.remove(KeyboardPage.EMOJI)
}
pages = DefaultValueLiveData(startingPages)
page = DefaultValueLiveData(startingPages.first())
StickerSearchRepository(ApplicationDependencies.getApplication()).getStickerFeatureAvailability { available ->
if (!available) {
val updatedPages = pages.value.toMutableSet().apply { remove(KeyboardPage.STICKER) }
pages.postValue(updatedPages)
if (page.value == KeyboardPage.STICKER) {
switchToPage(KeyboardPage.GIF)
switchToPage(KeyboardPage.EMOJI)
}
}
}
}
fun page(): LiveData<KeyboardPage> = page
fun pages(): LiveData<Set<KeyboardPage>> = pages
fun setOnlyPage(page: KeyboardPage) {
pages.value = setOf(page)
switchToPage(page)
}
fun switchToPage(page: KeyboardPage) {
if (this.pages.value.contains(page) && this.page.value != page) {
this.page.value = page
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.keyboard
import androidx.fragment.app.Fragment
/**
* Given an input type [T], find an instance of it first looking through all
* parents, and then the activity.
*
* @return First instance found of type [T] or null
*/
inline fun <reified T> Fragment.findListener(): T? {
var parent: Fragment? = parentFragment
while (parent != null) {
if (parent is T) {
return parent
}
parent = parent.parentFragment
}
return requireActivity() as? T
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.keyboard.emoji
import android.view.ViewGroup
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
class EmojiKeyboardPageAdapter(
private val emojiSelectionListener: EmojiKeyboardProvider.EmojiEventListener,
private val variationSelectorListener: EmojiPageViewGridAdapter.VariationSelectorListener,
private val searchCallbacks: KeyboardPageSearchView.Callbacks
) : MappingAdapter() {
init {
registerFactory(EmojiPageMappingModel::class.java) { parent ->
val pageView = EmojiPageView(parent.context, emojiSelectionListener, variationSelectorListener, true, searchCallbacks)
val layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
pageView.layoutParams = layoutParams
ViewHolder(pageView)
}
}
private class ViewHolder(
private val emojiPageView: EmojiPageView,
) : MappingViewHolder<EmojiPageMappingModel>(emojiPageView) {
override fun bind(model: EmojiPageMappingModel) {
emojiPageView.bindSearchableAdapter(model.emojiPageModel)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.keyboard.emoji
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconViewHolder
import org.thoughtcrime.securesms.util.MappingAdapter
class EmojiKeyboardPageCategoriesAdapter(private val onPageSelected: (String) -> Unit) : MappingAdapter() {
init {
registerFactory(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
registerFactory(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
}
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.keyboard.emoji
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.AttrRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.emoji.EmojiCategory
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconMappingModel
import org.thoughtcrime.securesms.util.ThemeUtil
sealed class EmojiKeyboardPageCategoryMappingModel(
override val key: String,
@AttrRes val iconId: Int,
override val selected: Boolean
) : KeyboardPageCategoryIconMappingModel<EmojiKeyboardPageCategoryMappingModel> {
override fun getIcon(context: Context): Drawable {
return requireNotNull(ThemeUtil.getThemedDrawable(context, iconId))
}
override fun areItemsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
return newItem.key == key
}
class RecentsMappingModel(selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(KEY, R.attr.emoji_category_recent, selected) {
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
return newItem is RecentsMappingModel && super.areContentsTheSame(newItem)
}
companion object {
const val KEY = "Recents"
}
}
class EmojiCategoryMappingModel(private val emojiCategory: EmojiCategory, selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(emojiCategory.key, emojiCategory.icon, selected) {
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
return newItem is EmojiCategoryMappingModel &&
super.areContentsTheSame(newItem) &&
newItem.emojiCategory == emojiCategory
}
}
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
return areItemsTheSame(newItem) && selected == newItem.selected
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.keyboard.emoji
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.emoji.EmojiCategory
import org.thoughtcrime.securesms.emoji.EmojiSource
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.thoughtcrime.securesms.util.MappingModelList
class EmojiKeyboardPageViewModel : ViewModel() {
private val internalSelectedKey = DefaultValueLiveData<String>(getStartingTab())
val selectedKey: LiveData<String>
get() = internalSelectedKey
val categories: LiveData<MappingModelList> = Transformations.map(internalSelectedKey) { selected ->
MappingModelList().apply {
add(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel(selected == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY))
EmojiCategory.values().forEach {
add(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel(it, it.key == selected))
}
}
}
val pages: LiveData<MappingModelList> = Transformations.map(categories) { categories ->
MappingModelList().apply {
categories.forEach {
add(getPageForCategory(it as EmojiKeyboardPageCategoryMappingModel))
}
}
}
fun onKeySelected(key: String) {
internalSelectedKey.value = key
}
private fun getPageForCategory(mappingModel: EmojiKeyboardPageCategoryMappingModel): EmojiPageMappingModel {
val page = if (mappingModel.key == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY) {
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)
} else {
EmojiSource.latest.displayPages.first { it.iconAttr == mappingModel.iconId }
}
return EmojiPageMappingModel(mappingModel.key, page)
}
fun addToRecents(emoji: String) {
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY).onCodePointSelected(emoji)
}
companion object {
fun getStartingTab(): String {
return if (RecentEmojiPageModel.hasRecents(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)) {
EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY
} else {
EmojiCategory.PEOPLE.key
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.keyboard.emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.util.MappingModel
class EmojiPageMappingModel(val key: String, val emojiPageModel: EmojiPageModel) : MappingModel<EmojiPageMappingModel> {
override fun areItemsTheSame(newItem: EmojiPageMappingModel): Boolean {
return key == newItem.key
}
override fun areContentsTheSame(newItem: EmojiPageMappingModel): Boolean {
return areItemsTheSame(newItem) &&
newItem.emojiPageModel.spriteUri == emojiPageModel.spriteUri &&
newItem.emojiPageModel.iconAttr == emojiPageModel.iconAttr
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.keyboard.emoji.search
import android.content.Context
import android.net.Uri
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
import org.thoughtcrime.securesms.emoji.EmojiSource
private const val MINIMUM_QUERY_THRESHOLD = 1
private const val EMOJI_SEARCH_LIMIT = 20
class EmojiSearchRepository(private val context: Context) {
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
fun submitQuery(query: String, consumer: (EmojiPageModel) -> Unit) {
if (query.length < MINIMUM_QUERY_THRESHOLD) {
consumer(RecentEmojiPageModel(context, EmojiKeyboardProvider.RECENT_STORAGE_KEY))
} else {
SignalExecutors.SERIAL.execute {
val emoji: List<String> = emojiSearchDatabase.query(query, EMOJI_SEARCH_LIMIT)
val variationMap: Map<String, String> = EmojiSource.latest.variationMap
val emojiVariationSets: MutableMap<String, LinkedHashSet<String>> = mutableMapOf()
variationMap
.filterKeys { emoji.contains(it) }
.forEach { (variation, canonical) ->
val set: LinkedHashSet<String> = emojiVariationSets.getOrDefault(canonical, linkedSetOf())
set.add(variation)
emojiVariationSets[canonical] = set
}
val displayEmoji: List<Emoji> = emoji.map { canonical ->
val variationSet: LinkedHashSet<String> = linkedSetOf(canonical).apply {
addAll(emojiVariationSets.getOrDefault(canonical, linkedSetOf()))
}
Emoji(variationSet.toList())
}
consumer(EmojiSearchResultsPageModel(emoji, displayEmoji))
}
}
}
private class EmojiSearchResultsPageModel(
private val emoji: List<String>,
private val displayEmoji: List<Emoji>
) : EmojiPageModel {
override fun getIconAttr(): Int = -1
override fun getEmoji(): List<String> = emoji
override fun getDisplayEmoji(): List<Emoji> = displayEmoji
override fun getSpriteUri(): Uri? = null
override fun isDynamic(): Boolean = false
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.keyboard.emoji.search
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
class EmojiSearchViewModel(private val repository: EmojiSearchRepository) : ViewModel() {
private val internalPageModel = MutableLiveData<EmojiPageModel>()
val pageModel: LiveData<EmojiPageModel> = internalPageModel
init {
onQueryChanged("")
}
fun onQueryChanged(query: String) {
repository.submitQuery(query, internalPageModel::postValue)
}
class Factory(private val repository: EmojiSearchRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(EmojiSearchViewModel(repository)))
}
}
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.keyboard.gif
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Fragment
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.mms.AttachmentManager
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
class GifKeyboardPageFragment : LoggingFragment(R.layout.gif_keyboard_page_fragment) {
private lateinit var host: Host
private lateinit var quickSearchAdapter: GifQuickSearchAdapter
private lateinit var giphyMp4ViewModel: GiphyMp4ViewModel
private lateinit var viewModel: GifKeyboardPageViewModel
private var progressDialog: AlertDialog? = null
private lateinit var quickSearchList: RecyclerView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
host = findListener<Host>() ?: throw AssertionError("Parent fragment or activity must implement Host")
childFragmentManager.beginTransaction()
.replace(R.id.gif_keyboard_giphy_frame, GiphyMp4Fragment.create(host.isMms()))
.commitAllowingStateLoss()
val searchKeyboard: KeyboardPageSearchView = view.findViewById(R.id.gif_keyboard_search_text)
searchKeyboard.callbacks = object : KeyboardPageSearchView.Callbacks {
override fun onClicked() {
openGifSearch()
}
}
view.findViewById<View>(R.id.gif_keyboard_search).setOnClickListener { openGifSearch() }
quickSearchList = view.findViewById(R.id.gif_keyboard_quick_search_recycler)
quickSearchAdapter = GifQuickSearchAdapter(this::onQuickSearchSelected)
quickSearchList.adapter = quickSearchAdapter
giphyMp4ViewModel = ViewModelProviders.of(requireActivity(), GiphyMp4ViewModel.Factory(host.isMms())).get(GiphyMp4ViewModel::class.java)
giphyMp4ViewModel.saveResultEvents.observe(viewLifecycleOwner, this::handleGiphyMp4SaveResult)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity()).get(GifKeyboardPageViewModel::class.java)
updateQuickSearchTabs()
}
private fun onQuickSearchSelected(gifQuickSearchOption: GifQuickSearchOption) {
if (viewModel.selectedTab == gifQuickSearchOption) {
return
}
viewModel.selectedTab = gifQuickSearchOption
giphyMp4ViewModel.updateSearchQuery(gifQuickSearchOption.query)
updateQuickSearchTabs()
}
private fun updateQuickSearchTabs() {
val quickSearches: List<GifQuickSearch> = GifQuickSearchOption.ranked
.map { search -> GifQuickSearch(search, search == viewModel.selectedTab) }
quickSearchAdapter.submitList(quickSearches, this::scrollToTab)
}
private fun scrollToTab() {
quickSearchList.post { quickSearchList.smoothScrollToPosition(GifQuickSearchOption.ranked.indexOf(viewModel.selectedTab)) }
}
private fun handleGiphyMp4SaveResult(result: GiphyMp4SaveResult) {
if (result is GiphyMp4SaveResult.Success) {
hideProgressDialog()
handleGiphyMp4SuccessfulResult(result)
} else if (result is GiphyMp4SaveResult.Error) {
hideProgressDialog()
handleGiphyMp4ErrorResult()
} else {
progressDialog = SimpleProgressDialog.show(requireContext())
}
}
private fun hideProgressDialog() {
progressDialog?.dismiss()
}
private fun handleGiphyMp4SuccessfulResult(success: GiphyMp4SaveResult.Success) {
host.onGifSelectSuccess(success.blobUri, success.width, success.height)
}
private fun handleGiphyMp4ErrorResult() {
Toast.makeText(requireContext(), R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show()
}
private fun openGifSearch() {
AttachmentManager.selectGif(requireActivity(), ConversationActivity.PICK_GIF, host.isMms())
}
interface Host {
fun isMms(): Boolean
fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int)
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.keyboard.gif
import androidx.lifecycle.ViewModel
class GifKeyboardPageViewModel : ViewModel() {
var selectedTab: GifQuickSearchOption = GifQuickSearchOption.TRENDING
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.keyboard.gif
import org.thoughtcrime.securesms.util.MappingModel
data class GifQuickSearch(val gifQuickSearchOption: GifQuickSearchOption, val selected: Boolean) : MappingModel<GifQuickSearch> {
override fun areItemsTheSame(newItem: GifQuickSearch): Boolean {
return gifQuickSearchOption == newItem.gifQuickSearchOption
}
override fun areContentsTheSame(newItem: GifQuickSearch): Boolean {
return selected == newItem.selected
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.keyboard.gif
import android.view.View
import android.widget.ImageView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
class GifQuickSearchAdapter(clickListener: (GifQuickSearchOption) -> Unit) : MappingAdapter() {
init {
registerFactory(GifQuickSearch::class.java, LayoutFactory({ v -> ViewHolder(v, clickListener) }, R.layout.keyboard_pager_category_icon))
}
private class ViewHolder(itemView: View, private val listener: (GifQuickSearchOption) -> Unit) : MappingViewHolder<GifQuickSearch>(itemView) {
private val image: ImageView = findViewById(R.id.category_icon)
private val imageSelected: View = findViewById(R.id.category_icon_selected)
override fun bind(model: GifQuickSearch) {
image.setImageResource(model.gifQuickSearchOption.image)
image.isSelected = model.selected
imageSelected.isSelected = model.selected
itemView.setOnClickListener { listener(model.gifQuickSearchOption) }
}
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.keyboard.gif
import org.thoughtcrime.securesms.R
enum class GifQuickSearchOption(private val rank: Int, val image: Int, val query: String) {
TRENDING(0, R.drawable.ic_gif_trending_24, ""),
CELEBRATE(1, R.drawable.ic_gif_celebrate_24, "celebrate"),
LOVE(2, R.drawable.ic_gif_love_24, "love"),
THUMBS_UP(3, R.drawable.ic_gif_thumbsup_24, "thumbs up"),
SURPRISED(4, R.drawable.ic_gif_surprised_24, "surprised"),
EXCITED(5, R.drawable.ic_gif_excited_24, "excited"),
SAD(6, R.drawable.ic_gif_sad_24, "sad"),
ANGRY(7, R.drawable.ic_gif_angry_24, "angry");
companion object {
val ranked: List<GifQuickSearchOption> by lazy { values().sortedBy { it.rank } }
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.keyboard.sticker
import androidx.lifecycle.ViewModel
class StickerKeyboardPageViewModel : ViewModel() {
var selectedTab: Int = 0
}

View File

@@ -0,0 +1,123 @@
package org.thoughtcrime.securesms.keyboard.sticker
import android.content.res.Configuration
import android.graphics.Point
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.Px
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.stickers.StickerKeyboardPageAdapter
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider
import org.thoughtcrime.securesms.util.DeviceProperties
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Search dialog for finding stickers.
*/
class StickerSearchDialogFragment : DialogFragment(), StickerKeyboardPageAdapter.EventListener {
private lateinit var search: KeyboardPageSearchView
private lateinit var list: RecyclerView
private lateinit var noResults: View
private lateinit var adapter: StickerKeyboardPageAdapter
private lateinit var layoutManager: GridLayoutManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_Animated_Bottom)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.sticker_search_dialog_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
search = view.findViewById(R.id.sticker_search_text)
list = view.findViewById(R.id.sticker_search_list)
noResults = view.findViewById(R.id.sticker_search_no_results)
adapter = StickerKeyboardPageAdapter(GlideApp.with(this), this, DeviceProperties.shouldAllowApngStickerAnimation(requireContext()))
layoutManager = GridLayoutManager(requireContext(), 2)
list.layoutManager = layoutManager
list.adapter = adapter
onScreenWidthChanged(getScreenWidth())
val viewModel: StickerSearchViewModel = ViewModelProviders.of(this, StickerSearchViewModel.Factory(requireContext())).get(StickerSearchViewModel::class.java)
viewModel.searchResults.observe(viewLifecycleOwner) { stickerRecords ->
adapter.setStickers(stickerRecords, calculateStickerSize(getScreenWidth()))
noResults.visibility = if (stickerRecords.isEmpty()) View.VISIBLE else View.GONE
}
search.enableBackNavigation()
search.callbacks = object : KeyboardPageSearchView.Callbacks {
override fun onQueryChanged(query: String) {
viewModel.query(query)
}
override fun onNavigationClicked() {
ViewUtil.hideKeyboard(requireContext(), view)
dismissAllowingStateLoss()
}
}
search.requestFocus()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
onScreenWidthChanged(getScreenWidth())
}
private fun onScreenWidthChanged(@Px newWidth: Int) {
layoutManager.spanCount = calculateColumnCount(newWidth)
adapter.setStickerSize(calculateStickerSize(newWidth))
}
private fun getScreenWidth(): Int {
val size = Point()
requireActivity().windowManager.defaultDisplay.getSize(size)
return size.x
}
private fun calculateColumnCount(@Px screenWidth: Int): Int {
val modifier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_padding).toFloat()
val divisor = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_divisor).toFloat()
return ((screenWidth - modifier) / divisor).toInt()
}
private fun calculateStickerSize(@Px screenWidth: Int): Int {
val multiplier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_multiplier).toFloat()
val columnCount = calculateColumnCount(screenWidth)
return ((screenWidth - (columnCount + 1) * multiplier) / columnCount).toInt()
}
companion object {
fun show(fragmentManager: FragmentManager) {
StickerSearchDialogFragment().show(fragmentManager, "TAG")
}
}
override fun onStickerClicked(sticker: StickerRecord) {
ViewUtil.hideKeyboard(requireContext(), requireView())
findListener<StickerKeyboardProvider.StickerEventListener>()?.onStickerSelected(sticker)
dismissAllowingStateLoss()
}
override fun onStickerLongClicked(targetView: View) = Unit
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.keyboard.sticker
import android.content.Context
import androidx.annotation.WorkerThread
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
import org.thoughtcrime.securesms.database.StickerDatabase
import org.thoughtcrime.securesms.database.StickerDatabase.StickerRecordReader
import org.thoughtcrime.securesms.database.model.StickerRecord
private const val RECENT_LIMIT = 24
private const val EMOJI_SEARCH_RESULTS_LIMIT = 20
class StickerSearchRepository(context: Context) {
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
private val stickerDatabase: StickerDatabase = DatabaseFactory.getStickerDatabase(context)
@WorkerThread
fun search(query: String): List<StickerRecord> {
if (query.isEmpty()) {
return StickerRecordReader(stickerDatabase.getRecentlyUsedStickers(RECENT_LIMIT)).readAll()
}
val maybeEmojiQuery: List<StickerRecord> = findStickersForEmoji(query)
val searchResults: List<StickerRecord> = emojiSearchDatabase.query(query, EMOJI_SEARCH_RESULTS_LIMIT)
.map { findStickersForEmoji(it) }
.flatten()
return maybeEmojiQuery + searchResults
}
@WorkerThread
private fun findStickersForEmoji(emoji: String): List<StickerRecord> {
val searchEmoji: String = EmojiUtil.getCanonicalRepresentation(emoji)
return EmojiUtil.getAllRepresentations(searchEmoji)
.filterNotNull()
.map { candidate -> StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate)).readAll() }
.flatten()
}
}
private fun StickerRecordReader.readAll(): List<StickerRecord> {
val stickers: MutableList<StickerRecord> = mutableListOf()
use { reader ->
var record: StickerRecord? = reader.next
while (record != null) {
stickers.add(record)
record = reader.next
}
}
return stickers
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.keyboard.sticker
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class StickerSearchViewModel(private val searchRepository: StickerSearchRepository) : ViewModel() {
private val searchQuery: MutableLiveData<String> = MutableLiveData("")
val searchResults: LiveData<List<StickerRecord>> = LiveDataUtil.mapAsync(searchQuery) { q -> searchRepository.search(q) }
fun query(query: String) {
searchQuery.postValue(query)
}
class Factory(context: Context) : ViewModelProvider.Factory {
val repository = StickerSearchRepository(context)
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(StickerSearchViewModel(repository)))
}
}
}