added emoji picker wit the feature of

1. able to get the recent used emoji
2. able to search emoji
This commit is contained in:
DumperTag 2024-09-04 13:32:07 +05:30
parent b22666eb24
commit 62b91f8436
623 changed files with 32230 additions and 162 deletions

View File

@ -103,4 +103,8 @@ dependencies {
implementation(libs.bundles.room)
ksp(libs.androidx.room.compiler)
implementation (project(":emojipicker"))
}

View File

@ -0,0 +1,197 @@
package com.simplemobiletools.keyboard.helpers;
import android.os.Bundle
import android.text.Editable
import android.text.Spanned
import android.text.style.SuggestionSpan
import android.util.Log
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.CompletionInfo
import android.view.inputmethod.CorrectionInfo
import android.view.inputmethod.ExtractedText
import android.view.inputmethod.ExtractedTextRequest
import android.widget.SearchView
import android.widget.TextView
/**
* Source: https://stackoverflow.com/a/39460124
*/
class OtherInputConnection(private val mTextView: androidx.appcompat.widget.AppCompatAutoCompleteTextView?) : BaseInputConnection(
mTextView!!, true
) {
// Keeps track of nested begin/end batch edit to ensure this connection always has a
// balanced impact on its associated TextView.
// A negative value means that this connection has been finished by the InputMethodManager.
private var mBatchEditNesting = 0
override fun getEditable(): Editable? {
val tv = mTextView
Log.i("heregotEditTEZT", tv!!.text.toString())
return tv?.editableText
}
override fun beginBatchEdit(): Boolean {
synchronized(this) {
if (mBatchEditNesting >= 0) {
mTextView!!.beginBatchEdit()
mBatchEditNesting++
return true
}
}
return false
}
override fun endBatchEdit(): Boolean {
synchronized(this) {
if (mBatchEditNesting > 0) {
// When the connection is reset by the InputMethodManager and reportFinish
// is called, some endBatchEdit calls may still be asynchronously received from the
// IME. Do not take these into account, thus ensuring that this IC's final
// contribution to mTextView's nested batch edit count is zero.
mTextView!!.endBatchEdit()
mBatchEditNesting--
return true
}
}
return false
}
//clear the meta key states means shift, alt, ctrl
override fun clearMetaKeyStates(states: Int): Boolean {
val content = editable ?: return false
val kl = mTextView!!.keyListener //listen keyevents like a, enter, space
if (kl != null) {
try {
kl.clearMetaKeyState(mTextView, content, states)
} catch (e: AbstractMethodError) {
// This is an old listener that doesn't implement the
// new method.
}
}
return true
}
//When a user selects a suggestion from an autocomplete or suggestion list, the input method may call commitCompletion
override fun commitCompletion(text: CompletionInfo): Boolean {
if (DEBUG) Log.v(
TAG,
"commitCompletion $text"
)
mTextView!!.beginBatchEdit()
mTextView.onCommitCompletion(text)
mTextView.endBatchEdit()
return true
}
/**
which is used to commit a correction to a previously entered text.
This correction could be suggested by the input method or obtained through some other means.
*/
override fun commitCorrection(correctionInfo: CorrectionInfo): Boolean {
if (DEBUG) Log.v(
TAG,
"commitCorrection$correctionInfo"
)
mTextView!!.beginBatchEdit()
mTextView.onCommitCorrection(correctionInfo)
mTextView.endBatchEdit()
return true
}
/* It's used to simulate the action associated with an editor action, typically triggered by pressing the "Done" or "Enter" key on the keyboard.*/
override fun performEditorAction(actionCode: Int): Boolean {
if (DEBUG) Log.v(
TAG,
"performEditorAction $actionCode"
)
mTextView!!.onEditorAction(actionCode)
return true
}
/*
handle actions triggered from the context menu associated with the search text.
This menu typically appears when you long-press on the search text field.
*/
override fun performContextMenuAction(id: Int): Boolean {
if (DEBUG) Log.v(
TAG,
"performContextMenuAction $id"
)
mTextView!!.beginBatchEdit()
mTextView.onTextContextMenuItem(id)
mTextView.endBatchEdit()
return true
}
/*It is used to retrieve information about the currently extracted text
* eg- selected text, the start and end offsets, the total number of characters, and more.*/
override fun getExtractedText(request: ExtractedTextRequest, flags: Int): ExtractedText? {
if (mTextView != null) {
val et = ExtractedText()
if (mTextView.extractText(request, et)) {
if (flags and GET_EXTRACTED_TEXT_MONITOR != 0) {
// mTextView.setExtracting(request);
}
return et
}
}
return null
}
// API to send private commands from an input method to its connected editor. This can be used to provide domain-specific features
override fun performPrivateCommand(action: String, data: Bundle): Boolean {
mTextView!!.onPrivateIMECommand(action, data)
return true
}
//send the text to the connected editor from the keyboard pressed
override fun commitText(
text: CharSequence,
newCursorPosition: Int
): Boolean {
if (mTextView == null) {
return super.commitText(text, newCursorPosition)
}
if (text is Spanned) {
val spans = text.getSpans(
0, text.length,
SuggestionSpan::class.java
)
// mIMM.registerSuggestionSpansForNotification(spans);
}
// mTextView.resetErrorChangedFlag();
// mTextView.hideErrorIfUnchanged();
return super.commitText(text, newCursorPosition)
}
override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean {
if (DEBUG) Log.v(
TAG,
"requestUpdateCursorAnchorInfo $cursorUpdateMode"
)
// It is possible that any other bit is used as a valid flag in a future release.
// We should reject the entire request in such a case.
val KNOWN_FLAGS_MASK = CURSOR_UPDATE_IMMEDIATE or CURSOR_UPDATE_MONITOR
val unknownFlags = cursorUpdateMode and KNOWN_FLAGS_MASK.inv()
if (unknownFlags != 0) {
if (DEBUG) {
Log.d(
TAG,
"Rejecting requestUpdateCursorAnchorInfo due to unknown flags. cursorUpdateMode=$cursorUpdateMode unknownFlags=$unknownFlags"
)
}
return false
}
return false
}
companion object {
private const val DEBUG = false
private val TAG = "loool"
}
}

View File

@ -41,4 +41,9 @@ interface OnKeyboardActionListener {
* Called to force the KeyboardView to reload the keyboard
*/
fun reloadKeyboard()
/*
* called when focus on the searchview */
fun searchViewFocused(searchView: androidx.appcompat.widget.AppCompatAutoCompleteTextView)
}

View File

@ -13,6 +13,7 @@ import android.os.Build
import android.os.Bundle
import android.text.InputType.*
import android.text.TextUtils
import android.util.Log
import android.util.Size
import android.view.KeyEvent
import android.view.View
@ -23,6 +24,8 @@ import android.view.inputmethod.EditorInfo.IME_FLAG_NO_ENTER_ACTION
import android.view.inputmethod.EditorInfo.IME_MASK_ACTION
import android.widget.inline.InlinePresentationSpec
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatAutoCompleteTextView
import androidx.appcompat.widget.SearchView
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.common.ImageViewStyle
import androidx.autofill.inline.common.TextViewStyle
@ -60,9 +63,17 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
private var enterKeyType = IME_ACTION_NONE
private var switchToLetters = false
private var breakIterator: BreakIterator? = null
private var otherInputConnection:OtherInputConnection? = null
private lateinit var binding: KeyboardViewKeyboardBinding
companion object{
/*true and false define the inputconnection where is input is send like
* if true-> send to emoji searchview
* if false -> send to currentinputconnection*/
var searching=false
}
override fun onInitializeInterface() {
super.onInitializeInterface()
safeStorageContext.getSharedPrefs().registerOnSharedPreferenceChangeListener(this)
@ -106,7 +117,7 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
val editorInfo = currentInputEditorInfo
if (config.enableSentencesCapitalization && editorInfo != null && editorInfo.inputType != TYPE_NULL) {
if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) {
if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0 && !searching) {
keyboard?.setShifted(ShiftState.ON_ONE_CHAR)
keyboardView?.invalidateAllKeys()
return
@ -149,7 +160,7 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
}
override fun onKey(code: Int) {
val inputConnection = currentInputConnection
val inputConnection = getMyCurrentInputConnection()
if (keyboard == null || inputConnection == null) {
return
}
@ -216,7 +227,10 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
}
MyKeyboard.KEYCODE_EMOJI -> {
keyboardView?.openEmojiPalette()
if(!searching){
keyboardView?.openEmojiPalette()
}
}
else -> {
@ -324,7 +338,8 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
}
override fun onText(text: String) {
currentInputConnection?.commitText(text, 1)
getMyCurrentInputConnection().commitText(text, 1)
}
override fun reloadKeyboard() {
@ -333,6 +348,10 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
keyboardView?.setKeyboard(keyboard)
}
override fun searchViewFocused(searchView: AppCompatAutoCompleteTextView) {
otherInputConnection = OtherInputConnection(searchView)
}
private fun createNewKeyboard(): MyKeyboard {
val keyboardXml = when (inputTypeClass) {
TYPE_CLASS_NUMBER -> {
@ -485,4 +504,20 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
return Icon.createWithData(byteArray, 0, byteArray.size)
}
fun getMyCurrentInputConnection():InputConnection{
if (searching){
if(otherInputConnection==null){
Log.i("thisISrunn", "yes2")
return currentInputConnection
}else{
Log.i("thisISrunn", "yes")
return otherInputConnection!!
}
}else{
Log.i("thisISrunn", "yes3")
return currentInputConnection
}
}
}

View File

@ -15,7 +15,11 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.text.Editable
import android.text.Html
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue
import android.view.*
import android.view.animation.AccelerateInterpolator
@ -34,6 +38,7 @@ import androidx.emoji2.text.EmojiCompat.EMOJI_SUPPORTED
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import androidx.recyclerview.widget.LinearLayoutManager
import com.rishabh.emojipicker.EmojiPickerView
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.helpers.isPiePlus
@ -60,6 +65,7 @@ import com.simplemobiletools.keyboard.interfaces.RefreshClipsListener
import com.simplemobiletools.keyboard.models.Clip
import com.simplemobiletools.keyboard.models.ClipsSectionLabel
import com.simplemobiletools.keyboard.models.ListItem
import com.simplemobiletools.keyboard.services.SimpleKeyboardIME.Companion.searching
import java.util.*
@SuppressLint("UseCompatLoadingForDrawables", "ClickableViewAccessibility")
@ -288,6 +294,7 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
accessHelper = AccessHelper(this, mKeyboard?.mKeys.orEmpty())
ViewCompat.setAccessibilityDelegate(this, accessHelper)
// Not really necessary to do every time, but will free up views
// Switching to a different keyboard should abort any pending keys so that the key up
// doesn't get delivered to the old or new keyboard
@ -297,7 +304,7 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
/** Sets the top row above the keyboard containing a couple buttons and the clipboard **/
fun setKeyboardHolder(binding: KeyboardViewKeyboardBinding) {
keyboardViewBinding = binding.apply {
mToolbarHolder = toolbarHolder
mToolbarHolder = mainToolbarKeyboardHolder
mClipboardManagerHolder = clipboardManagerHolder
mEmojiPaletteHolder = emojiPaletteHolder
@ -354,10 +361,7 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
}
}
emojiPaletteClose.setOnClickListener {
vibrateIfNeeded()
closeEmojiPalette()
}
}
}
@ -1439,10 +1443,19 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
private fun setupEmojiPalette(toolbarColor: Int, backgroundColor: Int, textColor: Int) {
keyboardViewBinding?.apply {
emojiPaletteTopBar.background = ColorDrawable(toolbarColor)
emojiSearchToolbar.background = ColorDrawable(toolbarColor)
emojiPaletteHolder.background = ColorDrawable(backgroundColor)
emojiPaletteClose.applyColorFilter(textColor)
emojiPaletteLabel.setTextColor(textColor)
emojiSearchView.setHintTextColor(mTextColor)
emojiSearchviewCross.applyColorFilter(textColor)
emojiSearchViewSearchIcon.applyColorFilter(textColor)
emojiSearchResult.background = ColorDrawable(backgroundColor)
emojiPaletteBottomBar.background = ColorDrawable(backgroundColor)
emojiPaletteModeChange.apply {
@ -1481,37 +1494,106 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
}
}
}
setupEmojis()
}
fun openEmojiPalette() {
keyboardViewBinding!!.emojiPaletteHolder.beVisible()
setupEmojis()
keyboardViewBinding!!.emojiSearchToolbar.beVisible()
keyboardViewBinding!!.mainToolbarKeyboardHolder.beGone()
/*when any emoji is picked from the emoji Picker*/
keyboardViewBinding!!.emojiPickerView.setOnEmojiPickedListener{
mOnKeyboardActionListener?.onText(it.emoji)
}
setupEmojiSearch()
}
private fun closeEmojiPalette() {
private fun setupEmojiSearch(){
keyboardViewBinding?.apply {
emojiPaletteHolder.beGone()
emojisList?.scrollToPosition(0)
emojiSearchView.setOnFocusChangeListener(object : View.OnFocusChangeListener{
override fun onFocusChange(v: View?, hasFocus: Boolean) {
emojiPaletteHolder.beGone()
mainToolbarKeyboardHolder.beGone()
// emojiSearchView.text.clear()
//change the input connection
searching=true
mOnKeyboardActionListener?.searchViewFocused(emojiSearchView)
//show emoji result
emojiSearchResult.beVisible()
/*It's s interface it runn when someone picked ffrom the emoji search result suggestion*/
val emojipickedFromSuggestion= object : EmojiPickerView.EmojiPickedFromSuggestion {
override fun pickedEmoji(emoji: String) {
searching =false
mOnKeyboardActionListener?.onText(emoji)
searching =true
}
}
emojiSearchResult.emojiPickedFromSuggestion = (emojipickedFromSuggestion)
/*when nothing is typed then in the showSearchResult show the recend only*/
keyboardViewBinding?.apply {
emojiSearchResult.bodyAdapter.hideTitleAndEmptyHint = true
emojiSearchResult.emojiPickerItems = emojiSearchResult.buildEmojiPickerItems(onlyRecentEmojies = true)
emojiSearchResult.bodyAdapter.notifyDataSetChanged()
}
}
})
emojiPaletteClose.setOnClickListener {
vibrateIfNeeded()
closeEmojiPalette()
}
emojiSearchviewCross.setOnClickListener {
emojiSearchView.text.clear()
}
emojiSearchView.addTextChangedListener(object:TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
emojiSearchResult.emojiPickerItems = emojiSearchResult.buildEmojiPickerItems(false,s.toString())
emojiSearchResult.bodyAdapter.notifyDataSetChanged()
}
})
}
}
private fun setupEmojis() {
ensureBackgroundThread {
val fullEmojiList = parseRawEmojiSpecsFile(context, EMOJI_SPEC_FILE_PATH)
val systemFontPaint = Paint().apply {
typeface = Typeface.DEFAULT
private fun closeEmojiPalette() {
keyboardViewBinding?.apply {
if(emojiPaletteHolder.isVisible){
emojiPaletteHolder.beGone()
emojiSearchToolbar.beGone()
mainToolbarKeyboardHolder.beVisible()
}else{
emojiSearchView.clearFocus()
emojiSearchView.text.clear()
emojiPaletteHolder.beVisible()
}
val emojis = fullEmojiList.filter { emoji ->
systemFontPaint.hasGlyph(emoji.emoji) || (EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED && EmojiCompat.get()
.getEmojiMatch(emoji.emoji, emojiCompatMetadataVersion) == EMOJI_SUPPORTED)
}
Handler(Looper.getMainLooper()).post {
setupEmojiAdapter(emojis)
}
searching =false
keyboardViewBinding?.emojiSearchResult?.beGone()
}
}
@ -1522,83 +1604,7 @@ class MyKeyboardView @JvmOverloads constructor(context: Context, attrs: Attribut
}
}
private fun setupEmojiAdapter(emojis: List<EmojiData>) {
val categories = emojis.groupBy { it.category }
val allItems = mutableListOf<EmojisAdapter.Item>()
categories.entries.forEach { (category, emojis) ->
allItems.add(EmojisAdapter.Item.Category(category))
allItems.addAll(emojis.map(EmojisAdapter.Item::Emoji))
}
val checkIds = mutableMapOf<Int, String>()
keyboardViewBinding?.emojiCategoriesStrip?.apply {
weightSum = categories.count().toFloat()
val strip = this
removeAllViews()
categories.entries.forEach { (category, emojis) ->
ItemEmojiCategoryBinding.inflate(LayoutInflater.from(context), this, true).apply {
root.id = generateViewId()
checkIds[root.id] = category
root.setImageResource(emojis.first().getCategoryIcon())
root.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
1f
)
root.setOnClickListener {
strip.children.filterIsInstance<ImageButton>().forEach {
it.imageTintList = ColorStateList.valueOf(mTextColor)
}
root.imageTintList = ColorStateList.valueOf(context.getProperPrimaryColor())
keyboardViewBinding?.emojisList?.stopScroll()
(keyboardViewBinding?.emojisList?.layoutManager as? GridLayoutManager)?.scrollToPositionWithOffset(
allItems.indexOfFirst { it is EmojisAdapter.Item.Category && it.value == category },
0
)
}
root.imageTintList = ColorStateList.valueOf(mTextColor)
}
}
}
keyboardViewBinding?.emojisList?.apply {
val emojiItemWidth = context.resources.getDimensionPixelSize(R.dimen.emoji_item_size)
val emojiTopBarElevation = context.resources.getDimensionPixelSize(R.dimen.emoji_top_bar_elevation).toFloat()
layoutManager = AutoGridLayoutManager(context, emojiItemWidth).apply {
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int =
if (allItems[position] is EmojisAdapter.Item.Category) {
spanCount
} else {
1
}
}
}
adapter = EmojisAdapter(context = context, items = allItems) { emoji ->
mOnKeyboardActionListener!!.onText(emoji.emoji)
vibrateIfNeeded()
}
clearOnScrollListeners()
onScroll {
keyboardViewBinding!!.emojiPaletteTopBar.elevation = if (it > 4) emojiTopBarElevation else 0f
(keyboardViewBinding?.emojisList?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition()?.also { firstVisibleIndex ->
allItems
.withIndex()
.lastOrNull { it.value is EmojisAdapter.Item.Category && it.index <= firstVisibleIndex }
?.also { activeCategory ->
val id = checkIds.entries.first { it.value == (activeCategory.value as EmojisAdapter.Item.Category).value }.key
keyboardViewBinding?.emojiCategoriesStrip?.children?.filterIsInstance<ImageButton>()?.forEach {
if (it.id == id) {
it.imageTintList = ColorStateList.valueOf(context.getProperPrimaryColor())
} else {
it.imageTintList = ColorStateList.valueOf(mTextColor)
}
}
}
}
}
}
}
private fun closing() {
if (mPreviewPopup.isShowing) {

View File

@ -1,5 +1,5 @@
<com.simplemobiletools.commons.views.MyRecyclerView
android:id="@+id/emojis_list"
android:id="@+id/emojiPickerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"

View File

@ -5,8 +5,90 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.rishabh.emojipicker.EmojiPickerView
android:id="@+id/emojiSearchResult"
app:usedInSearchResult="true"
android:layout_width="match_parent"
android:layout_height="170dp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/emoji_search_toolbar"
/>
<!--top bar of emoji search-->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbar_holder"
android:id="@+id/emoji_search_toolbar"
android:layout_width="match_parent"
android:layout_height="@dimen/toolbar_height"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/keyboard_view"
android:visibility="gone"
android:gravity="center_vertical">
<ImageView
android:id="@+id/emoji_palette_close"
android:layout_width="@dimen/toolbar_icon_height"
android:layout_height="@dimen/toolbar_icon_height"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/medium_margin"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="@dimen/small_margin"
android:src="@drawable/ic_arrow_left_vector"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- add a linearlayout with horizontal orientaion and add two button first at start and end-->
<!-- then at start is search and end is cross button -->
<ImageView
android:id="@+id/emojiSearchViewSearchIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center_vertical|left"
android:layout_marginStart="10dp"
android:src="@drawable/ic_search_vector"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/emoji_palette_close"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/emoji_search_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toEndOf="@+id/emoji_palette_close"
android:completionThreshold="1"
android:theme="@style/Theme.AppCompat.DayNight"
android:focusable="true"
android:hint="@string/search_emoji"
android:focusableInTouchMode="true"
android:padding="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/emojiSearchviewCross"
app:layout_constraintStart_toEndOf="@+id/emojiSearchViewSearchIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/emojiSearchviewCross"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginRight="16dp"
android:src="@drawable/ic_cross_vector"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/emoji_search_view"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!--toolbar of main keyboard-->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainToolbarKeyboardHolder"
android:layout_width="match_parent"
android:layout_height="@dimen/toolbar_height"
android:layout_above="@+id/keyboard_view"
@ -121,6 +203,8 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!--emoji section-->
<RelativeLayout
android:id="@+id/emoji_palette_holder"
android:layout_width="match_parent"
@ -131,50 +215,18 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/toolbar_holder">
app:layout_constraintTop_toBottomOf="@id/emoji_search_toolbar">
<RelativeLayout
android:id="@+id/emoji_palette_top_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/toolbar_height"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:gravity="center_vertical">
<ImageView
android:id="@+id/emoji_palette_close"
android:layout_width="@dimen/toolbar_icon_height"
android:layout_height="@dimen/toolbar_icon_height"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/medium_margin"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/emojis"
android:padding="@dimen/small_margin"
android:src="@drawable/ic_arrow_left_vector" />
<TextView
android:id="@+id/emoji_palette_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/medium_margin"
android:layout_toEndOf="@+id/emoji_palette_close"
android:ellipsize="end"
android:lines="1"
android:text="@string/emojis"
android:textSize="@dimen/big_text_size" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/emoji_content_holder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/emoji_palette_bottom_bar"
android:layout_below="@+id/emoji_palette_top_bar">
android:layout_above="@id/emoji_palette_bottom_bar">
<com.simplemobiletools.commons.views.MyRecyclerView
android:id="@+id/emojis_list"
<com.rishabh.emojipicker.EmojiPickerView
android:id="@+id/emojiPickerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
@ -237,7 +289,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/toolbar_holder">
app:layout_constraintTop_toTopOf="@+id/mainToolbarKeyboardHolder">
<RelativeLayout
android:id="@+id/clipboard_manager_top_bar"

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">بدء الجمل بحرف كبير</string>
<!-- Emojis -->
<string name="emojis">الرموز التعبيرية</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Эмодзі</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Емоджита</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Comença les frases amb una lletra majúscula</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">گەورەکردنی یەکەم پیتی لاتینی</string>
<!-- Emojis -->
<string name="emojis">خەندەکان</string>
</resources>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Začínat věty velkým písmenem</string>
<!-- Emojis -->
<string name="emojis">Emotikony</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Sätze mit einem Großbuchstaben beginnen</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Αρχίστε τις προτάσεις με κεφαλαίο γράμμα</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Empezar las frases con mayúsculas</string>
<!-- Emojis -->
<string name="emojis">Emoticonos</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Alusta lauseid suurtähega</string>
<!-- Emojis -->
<string name="emojis">Emojid</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojit</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Commencer les phrases par une majuscule</string>
<!-- Emojis -->
<string name="emojis">Émojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emoticona</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Počni rečenice s velikim slovom</string>
<!-- Emojis -->
<string name="emojis">Emoji</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojik</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Mulai kalimat dengan huruf kapital</string>
<!-- Emojis -->
<string name="emojis">Emoji</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Inizia le frasi con la lettera maiuscola</string>
<!-- Emojis -->
<string name="emojis">Emoji</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">絵文字</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Sākt teikumus ar lielo burtu</string>
<!-- Emojis -->
<string name="emojis">Emocijzīmes</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">ഇമോജികൾ</string>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Zinnen met een hoofdletter beginnen</string>
<!-- Emojis -->
<string name="emojis">Emoji\'s</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">ایموجیاں</string>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">ਵਾਕ ਵੱਡੇ ਅੱਖਰ ਨਾਲ ਸ਼ੁਰੂ ਕਰੋ</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
</resources>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Zaczynaj zdania wielką literą</string>
<!-- Emojis -->
<string name="emojis">Emoji</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Iniciar frases com letra maiúscula</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emoticoane</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Начинать предложения с заглавной буквы</string>
<!-- Emojis -->
<string name="emojis">Эмодзи</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Začať vety veľkým písmenom</string>
<!-- Emojis -->
<string name="emojis">Emoji</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emoji-ji</string>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,4 +38,5 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Емоји</string>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Börja meningar med stor bokstav</string>
<!-- Emojis -->
<string name="emojis">Emojier</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">Cümlelere büyük harfle başla</string>
<!-- Emojis -->
<string name="emojis">Emojiler</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Починати речення з великої літери</string>
<!-- Emojis -->
<string name="emojis">Емодзі</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="search_emoji">Search Emoji</string>
</resources>

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Bắt đầu câu bằng chữ in hoa</string>
<!-- Emojis -->
<string name="emojis">Biểu tượng cảm xúc</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,8 +38,9 @@
<string name="start_sentences_capitalized">句子开头使用大写字母</string>
<!-- Emojis -->
<string name="emojis">表情符号</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res
-->
</resources>
</resources>

View File

@ -39,6 +39,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -38,6 +38,7 @@
<string name="start_sentences_capitalized">Start sentences with a capital letter</string>
<!-- Emojis -->
<string name="emojis">Emojis</string>
<string name="search_emoji">Search Emoji</string>
<!--
Haven't found some strings? There's more at
https://github.com/SimpleMobileTools/Simple-Commons/tree/master/commons/src/main/res

View File

@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android).apply(false)
alias(libs.plugins.kotlinAndroid).apply(false)
alias(libs.plugins.ksp).apply(false)
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.kotlinAndroid) apply false
}

15
emojipicker/.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
emojipicker/.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Android Vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.beetleInk.memest" />
</ConnectionSetting>
</option>
<option name="failureTypes">
<list>
<option value="FATAL" />
</list>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

6
emojipicker/.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

19
emojipicker/.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

6
emojipicker/.idea/kotlinc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.22" />
</component>
</project>

10
emojipicker/.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
emojipicker/.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

1
emojipicker/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,60 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.rishabh.emojipicker"
compileSdk = 34
defaultConfig {
applicationId = "com.rishabh.emojipicker"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.emoji2:emoji2:1.4.0")
implementation("com.google.firebase:firebase-crashlytics-buildtools:3.0.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
api("androidx.core:core:1.9.0")
// Implementations
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.22")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
androidTestImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
}

21
emojipicker/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<uses-library android:name="android.test.runner"/>
<activity android:name="androidx.emoji2.emojipicker.EmojiPickerViewTestActivity"/>
<activity android:name="androidx.emoji2.emojipicker.EmojiViewTestActivity" />
</application>
</manifest>

View File

@ -0,0 +1,71 @@
package com.rishabh.emojipicker
import android.content.Context
import com.rishabh.emojipicker.utils.FileCache
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@SmallTest
class BundledEmojiListLoaderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
@Test
fun testGetCategorizedEmojiData_loaded_writeToCache() = runBlocking {
// delete cache dir first
val fileCache = FileCache.getInstance(context)
fileCache.emojiPickerCacheDir.deleteRecursively()
assertFalse(fileCache.emojiPickerCacheDir.exists())
BundledEmojiListLoader.load(context)
val result = BundledEmojiListLoader.getCategorizedEmojiData()
assertTrue(result.isNotEmpty())
// emoji_picker/osVersion|appVersion/ folder should be created
val propertyFolder = fileCache.emojiPickerCacheDir.listFiles()!![0]
assertTrue(propertyFolder!!.isDirectory)
// Number of cache files should match the size of categorizedEmojiData
val cacheFiles = propertyFolder.listFiles()
assertTrue(cacheFiles!!.size == result.size)
}
@Test
fun testGetCategorizedEmojiData_loaded_readFromCache() = runBlocking {
// delete cache and load again
val fileCache = FileCache.getInstance(context)
fileCache.emojiPickerCacheDir.deleteRecursively()
BundledEmojiListLoader.load(context)
val cacheFileName = fileCache.emojiPickerCacheDir.listFiles()!![0].listFiles()!![0].name
val emptyDefaultValue = listOf<EmojiViewItem>()
// Read from cache instead of using default value
var output = fileCache.getOrPut(cacheFileName) { emptyDefaultValue }
assertTrue(output.isNotEmpty())
// Remove cache, write default value to cache
fileCache.emojiPickerCacheDir.deleteRecursively()
output = fileCache.getOrPut(cacheFileName) { emptyDefaultValue }
assertTrue(output.isEmpty())
}
@Test
@SdkSuppress(minSdkVersion = 24)
fun testGetEmojiVariantsLookup_loaded() = runBlocking {
// delete cache and load again
FileCache.getInstance(context).emojiPickerCacheDir.deleteRecursively()
BundledEmojiListLoader.load(context)
val result = BundledEmojiListLoader.getEmojiVariantsLookup()
// 👃 has variants (👃,👃,👃🏻,👃🏼,👃🏽,👃🏾,👃🏿)
assertTrue(result["\uD83D\uDC43"]!!.contains("\uD83D\uDC43\uD83C\uDFFD"))
// 😀 has no variant
assertFalse(result.containsKey("\uD83D\uDE00"))
}
}

View File

@ -0,0 +1,233 @@
package com.rishabh.emojipicker
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.children
import androidx.core.view.isVisible
import com.rishabh.emojipicker.R as EmojiPickerViewR
import com.rishabh.emojipicker.test.R
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.BoundedMatcher
import androidx.test.espresso.matcher.RootMatchers.hasWindowLayoutParams
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.Description
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
class EmojiPickerViewTestActivity : Activity() {
lateinit var emojiPickerView: EmojiPickerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.inflation_test)
emojiPickerView = findViewById(R.id.emojiPickerTest)
}
}
@LargeTest
@RunWith(AndroidJUnit4::class)
class EmojiPickerViewTest {
private lateinit var context: Context
@get:Rule val activityTestRule = ActivityScenarioRule(EmojiPickerViewTestActivity::class.java)
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun testCustomEmojiPickerView_rendered() {
activityTestRule.scenario.onActivity {
val mEmojiPickerView = it.findViewById<EmojiPickerView>(R.id.emojiPickerTest)
assert(mEmojiPickerView.isVisible)
assertEquals(mEmojiPickerView.emojiGridColumns, 10)
}
}
@Test
fun testCustomEmojiPickerView_noVariant() {
activityTestRule.scenario.onActivity {
val targetView = findViewByEmoji(it.findViewById(R.id.emojiPickerTest), GRINNING_FACE)!!
// Not long-clickable
assertEquals(targetView.isLongClickable, false)
}
}
@Test
@SdkSuppress(minSdkVersion = 24)
fun testCustomEmojiPickerView_hasVariant() {
lateinit var view: EmojiPickerView
activityTestRule.scenario.onActivity { view = it.findViewById(R.id.emojiPickerTest) }
findViewByEmoji(view, NOSE_EMOJI)
?: onView(withId(EmojiPickerViewR.id.emoji_picker_body))
.perform(
RecyclerViewActions.scrollToHolder(createEmojiViewHolderMatcher(NOSE_EMOJI))
)
val targetView = findViewByEmoji(view, NOSE_EMOJI)!!
// Long-clickable
assertEquals(targetView.isLongClickable, true)
}
@Test
@SdkSuppress(minSdkVersion = 24)
@Ignore("b/294556440")
fun testStickyVariant_displayAndSaved() {
lateinit var view: EmojiPickerView
activityTestRule.scenario.onActivity { view = it.findViewById(R.id.emojiPickerTest) }
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// Scroll to the nose emoji, long click then select nose in dark skin tone
findViewByEmoji(view, NOSE_EMOJI)
?: onView(withId(EmojiPickerViewR.id.emoji_picker_body))
.perform(
RecyclerViewActions.scrollToHolder(createEmojiViewHolderMatcher(NOSE_EMOJI))
)
onView(createEmojiViewMatcher(NOSE_EMOJI)).perform(longClick())
onView(createEmojiViewMatcher(NOSE_EMOJI_DARK))
.inRoot(hasWindowLayoutParams())
.perform(click())
assertNotNull(findViewByEmoji(view, NOSE_EMOJI_DARK))
// Switch back to clear saved preference
onView(createEmojiViewMatcher(NOSE_EMOJI_DARK)).perform(longClick())
onView(createEmojiViewMatcher(NOSE_EMOJI)).inRoot(hasWindowLayoutParams()).perform(click())
assertNotNull(findViewByEmoji(view, NOSE_EMOJI))
}
@Ignore // b/260915957
@Test
fun testHeader_highlightCurrentCategory() {
disableRecent()
assertSelectedHeaderIndex(0)
scrollToEmoji(NOSE_EMOJI)
assertSelectedHeaderIndex(1)
scrollToEmoji(BAT)
assertSelectedHeaderIndex(3)
scrollToEmoji(KEY)
assertSelectedHeaderIndex(7)
}
@Test
fun testHeader_clickingIconWillScrollToCategory() {
onView(createEmojiViewMatcher(STRAWBERRY)).check { view, _ -> assertNull(view) }
onView(withId(EmojiPickerViewR.id.emoji_picker_header))
.perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(4, click())
)
onView(createEmojiViewMatcher(STRAWBERRY)).check { view, _ -> assertNotNull(view) }
assertSelectedHeaderIndex(4)
}
@Test(expected = UnsupportedOperationException::class)
fun testAddView_throwsException() {
activityTestRule.scenario.onActivity { it.emojiPickerView.addView(View(context)) }
}
private fun findViewByEmoji(root: View, emoji: String) =
try {
mutableListOf<EmojiView>()
.apply { findEmojiViews(root, this) }
.first { it.emoji == emoji }
} catch (e: NoSuchElementException) {
null
}
private fun findEmojiViews(root: View, output: MutableList<EmojiView>) {
if (root !is ViewGroup) {
return
}
for (i in 0 until root.childCount) {
root
.getChildAt(i)
.apply {
if (this is EmojiView) {
output.add(this)
}
}
.also { findEmojiViews(it, output) }
}
}
private fun createEmojiViewHolderMatcher(emoji: String) =
object :
BoundedMatcher<RecyclerView.ViewHolder, EmojiViewHolder>(EmojiViewHolder::class.java) {
override fun describeTo(description: Description) {}
override fun matchesSafely(item: EmojiViewHolder) =
(item.itemView as EmojiView).emoji == emoji
}
private fun createEmojiViewMatcher(emoji: String) =
object : BoundedMatcher<View, EmojiView>(EmojiView::class.java) {
override fun describeTo(description: Description) {}
override fun matchesSafely(item: EmojiView) = item.emoji == emoji
}
private fun assertSelectedHeaderIndex(expected: Int) =
onView(withId(EmojiPickerViewR.id.emoji_picker_header)).check { view, noViewFoundException
->
view ?: throw noViewFoundException
val selectedIndex =
(view as RecyclerView)
.children
.withIndex()
.single { (_, view) ->
view
.findViewById<ImageView>(EmojiPickerViewR.id.emoji_picker_header_icon)
.isSelected
}
.index
assertEquals(expected, selectedIndex)
}
private fun scrollToEmoji(emoji: String) =
onView(withId(EmojiPickerViewR.id.emoji_picker_body))
.perform(RecyclerViewActions.scrollToHolder(createEmojiViewHolderMatcher(emoji)))
private fun disableRecent() {
activityTestRule.scenario.onActivity {
it.emojiPickerView.setRecentEmojiProvider(
object : RecentEmojiProvider {
override fun recordSelection(emoji: String) {}
override suspend fun getRecentEmojiList(): List<String> = listOf()
}
)
}
}
companion object {
const val GRINNING_FACE = "\uD83D\uDE00"
const val NOSE_EMOJI = "\uD83D\uDC43"
const val NOSE_EMOJI_DARK = "\uD83D\uDC43\uD83C\uDFFF"
const val BAT = "\uD83E\uDD87"
const val KEY = "\uD83D\uDD11"
const val STRAWBERRY = "\uD83C\uDF53"
}
}

View File

@ -0,0 +1,94 @@
package com.rishabh.emojipicker
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Color
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.graphics.applyCanvas
import androidx.core.text.toSpanned
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.screenshot.AndroidXScreenshotTestRule
import androidx.test.screenshot.assertAgainstGolden
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
class EmojiViewTestActivity : Activity()
@RunWith(AndroidJUnit4::class)
@SmallTest
class EmojiViewTest {
companion object {
private const val GRINNING_FACE = "\uD83D\uDE00"
}
@get:Rule val screenshotRule = AndroidXScreenshotTestRule("emoji2/emoji2-emojipicker")
@get:Rule val activityRule = ActivityScenarioRule(EmojiViewTestActivity::class.java)
private lateinit var emojiView: EmojiView
@Before
fun setUp() {
activityRule.scenario.onActivity {
emojiView = EmojiView(it)
it.setContentView(emojiView)
}
}
private fun setAndWait(cs: CharSequence?) {
emojiView.emoji = cs
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
}
private fun dumpAndAssertAgainstGolden(golden: String) {
Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888)
.applyCanvas { emojiView.draw(this) }
.assertAgainstGolden(screenshotRule, golden)
}
@Test
fun testDrawEmoji() {
setAndWait(GRINNING_FACE)
dumpAndAssertAgainstGolden("draw_grinning_face")
}
@Test
fun testDrawSpannedString() {
setAndWait(
SpannableString("0")
.apply {
setSpan(ForegroundColorSpan(Color.RED), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
.toSpanned()
)
dumpAndAssertAgainstGolden("draw_red_zero")
}
@Test
fun testMultipleDraw() {
setAndWait(GRINNING_FACE)
setAndWait("M")
dumpAndAssertAgainstGolden("multiple_draw")
}
@Ignore
@Test
fun testClear() {
setAndWait(GRINNING_FACE)
setAndWait(null)
dumpAndAssertAgainstGolden("draw_and_clear")
}
}

View File

@ -0,0 +1,31 @@
package com.rishabh.emojipicker.utils
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import org.junit.Assert.assertEquals
import org.junit.Test
@SmallTest
class UnicodeRenderableManagerTest {
@Test
@SdkSuppress(minSdkVersion = 21, maxSdkVersion = 23)
fun testGetClosestRenderable_lowerVersionTrimmed() {
// #️⃣
assertEquals(
UnicodeRenderableManager.getClosestRenderable("\u0023\uFE0F\u20E3"),
"\u0023\u20E3"
)
}
@Test
@SdkSuppress(minSdkVersion = 24)
fun testGetClosestRenderable_higherVersionNoTrim() {
// #️⃣
assertEquals(
UnicodeRenderableManager.getClosestRenderable("\u0023\uFE0F\u20E3"),
"\u0023\uFE0F\u20E3"
)
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.emoji2.emojipicker.EmojiPickerView
android:id="@+id/emojiPickerTest"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:emojiGridColumns="10"
app:emojiGridRows="8.5"/>
</LinearLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,8 @@
# Module root
androidx.emoji2 emoji2-emojipicker
# Package androidx.emoji2.emojipicker
This library provides the latest emoji support and emoji picker UI including
skin-tone variants and emoji compat support.

View File

@ -0,0 +1,8 @@
# Module root
androidx.emoji2 emoji2-emojipicker
# package reeshabh.emojipicker
This library provides the latest emoji support and emoji picker UI including
skin-tone variants and emoji compat support.

View File

@ -0,0 +1,134 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.content.res.TypedArray
import androidx.annotation.DrawableRes
import androidx.core.content.res.use
import androidx.emoji2.emojipicker.utils.FileCache
import androidx.emoji2.emojipicker.utils.UnicodeRenderableManager
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/**
* A data loader that loads the following objects either from file based caches or from resources.
*
* categorizedEmojiData: a list that holds bundled emoji separated by category, filtered by
* renderability check. This is the data source for EmojiPickerView.
*
* emojiVariantsLookup: a map of emoji variants in bundled emoji, keyed by the base emoji. This
* allows faster variants lookup.
*
* primaryEmojiLookup: a map of base emoji to its variants in bundled emoji. This allows faster
* variants lookup.
*/
internal object BundledEmojiListLoader {
private var categorizedEmojiData: List<EmojiDataCategory>? = null
private var emojiVariantsLookup: Map<String, List<String>>? = null
internal suspend fun load(context: Context) {
val categoryNames = context.resources.getStringArray(R.array.category_names)
val categoryHeaderIconIds =
context.resources.obtainTypedArray(R.array.emoji_categories_icons).use { typedArray ->
IntArray(typedArray.length()) { typedArray.getResourceId(it, 0) }
}
val resources =
if (UnicodeRenderableManager.isEmoji12Supported())
R.array.emoji_by_category_raw_resources_gender_inclusive
else R.array.emoji_by_category_raw_resources
val emojiFileCache = FileCache.getInstance(context)
categorizedEmojiData =
context.resources.obtainTypedArray(resources).use { ta ->
loadEmoji(ta, categoryHeaderIconIds, categoryNames, emojiFileCache, context)
}
emojiVariantsLookup =
categorizedEmojiData!!
.flatMap { it.emojiDataList }
.filter { it.variants.isNotEmpty() }
.flatMap { it.variants.map { variant -> EmojiViewItem(variant, it.variants) } }
.associate { it.emoji to it.variants }
.also { emojiVariantsLookup = it }
}
internal fun getCategorizedEmojiData() =
categorizedEmojiData
?: throw IllegalStateException("BundledEmojiListLoader.load is not called or complete")
internal fun getEmojiVariantsLookup() =
emojiVariantsLookup
?: throw IllegalStateException("BundledEmojiListLoader.load is not called or complete")
private suspend fun loadEmoji(
ta: TypedArray,
@DrawableRes categoryHeaderIconIds: IntArray,
categoryNames: Array<String>,
emojiFileCache: FileCache,
context: Context
): List<EmojiDataCategory> = coroutineScope {
(0 until ta.length())
.map {
async {
emojiFileCache
.getOrPut(getCacheFileName(it)) {
loadSingleCategory(context, ta.getResourceId(it, 0))
}
.let { data ->
EmojiDataCategory(categoryHeaderIconIds[it], categoryNames[it], data)
}
}
}
.awaitAll()
}
private fun loadSingleCategory(
context: Context,
resId: Int,
): List<EmojiViewItem> =
context.resources
.openRawResource(resId)
.bufferedReader()
.useLines { it.toList() }
.map { filterRenderableEmojis(it.split(",")) }
.filter { it.isNotEmpty() }
.map { EmojiViewItem(it.first(), it.drop(1)) }
private fun getCacheFileName(categoryIndex: Int) =
StringBuilder()
.append("emoji.v1.")
.append(if (EmojiPickerView.emojiCompatLoaded) 1 else 0)
.append(".")
.append(categoryIndex)
.append(".")
.append(if (UnicodeRenderableManager.isEmoji12Supported()) 1 else 0)
.toString()
/**
* To eliminate 'Tofu' (the fallback glyph when an emoji is not renderable), check the
* renderability of emojis and keep only when they are renderable on the current device.
*/
private fun filterRenderableEmojis(emojiList: List<String>) =
emojiList.filter { UnicodeRenderableManager.isEmojiRenderable(it) }.toList()
internal data class EmojiDataCategory(
@DrawableRes val headerIconId: Int,
val categoryName: String,
val emojiDataList: List<EmojiViewItem>
)
}

View File

@ -0,0 +1,56 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.content.Context.MODE_PRIVATE
/**
* Provides recently shared emoji. This is the default recent emoji list provider. Clients could
* specify the provider by their own.
*/
internal class DefaultRecentEmojiProvider(context: Context) : RecentEmojiProvider {
companion object {
private const val PREF_KEY_RECENT_EMOJI = "pref_key_recent_emoji"
private const val RECENT_EMOJI_LIST_FILE_NAME = "androidx.emoji2.emojipicker.preferences"
private const val SPLIT_CHAR = ","
}
private val sharedPreferences =
context.getSharedPreferences(RECENT_EMOJI_LIST_FILE_NAME, MODE_PRIVATE)
private val recentEmojiList: MutableList<String> =
sharedPreferences.getString(PREF_KEY_RECENT_EMOJI, null)?.split(SPLIT_CHAR)?.toMutableList()
?: mutableListOf()
override suspend fun getRecentEmojiList(): List<String> {
return recentEmojiList
}
override fun recordSelection(emoji: String) {
recentEmojiList.remove(emoji)
recentEmojiList.add(0, emoji)
saveToPreferences()
}
private fun saveToPreferences() {
sharedPreferences
.edit()
.putString(PREF_KEY_RECENT_EMOJI, recentEmojiList.joinToString(SPLIT_CHAR))
.commit()
}
}

View File

@ -0,0 +1,139 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.annotation.UiThread
import androidx.core.view.ViewCompat
import androidx.emoji2.emojipicker.Extensions.toItemType
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
/** RecyclerView adapter for emoji body. */
internal class EmojiPickerBodyAdapter(
private val context: Context,
private val emojiGridColumns: Int,
private val emojiGridRows: Float?,
private val stickyVariantProvider: StickyVariantProvider,
private val emojiPickerItemsProvider: () -> EmojiPickerItems,
private val onEmojiPickedListener: EmojiPickerBodyAdapter.(EmojiViewItem) -> Unit,
) : Adapter<ViewHolder>() {
private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
private var emojiCellWidth: Int? = null
private var emojiCellHeight: Int? = null
@UiThread
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
emojiCellWidth = emojiCellWidth ?: (getParentWidth(parent) / emojiGridColumns)
emojiCellHeight =
emojiCellHeight
?: emojiGridRows?.let { getEmojiCellTotalHeight(parent) / it }?.toInt()
?: emojiCellWidth
return when (viewType.toItemType()) {
ItemType.CATEGORY_TITLE -> createSimpleHolder(R.layout.category_text_view, parent)
ItemType.PLACEHOLDER_TEXT ->
createSimpleHolder(R.layout.empty_category_text_view, parent) {
minimumHeight = emojiCellHeight!!
}
ItemType.EMOJI -> {
EmojiViewHolder(
context,
emojiCellWidth!!,
emojiCellHeight!!,
stickyVariantProvider,
onEmojiPickedListener = { emojiViewItem ->
onEmojiPickedListener(emojiViewItem)
},
onEmojiPickedFromPopupListener = { emoji ->
val baseEmoji = BundledEmojiListLoader.getEmojiVariantsLookup()[emoji]!![0]
emojiPickerItemsProvider().forEachIndexed { index, itemViewData ->
if (
itemViewData is EmojiViewData &&
BundledEmojiListLoader.getEmojiVariantsLookup()[
itemViewData.emoji]
?.get(0) == baseEmoji &&
itemViewData.updateToSticky
) {
itemViewData.emoji = emoji
notifyItemChanged(index)
}
}
}
)
}
}
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val item = emojiPickerItemsProvider().getBodyItem(position)
when (getItemViewType(position).toItemType()) {
ItemType.CATEGORY_TITLE ->
ViewCompat.requireViewById<TextView>(viewHolder.itemView, R.id.category_name).text =
(item as CategoryTitle).title
ItemType.PLACEHOLDER_TEXT ->
ViewCompat.requireViewById<TextView>(
viewHolder.itemView,
R.id.emoji_picker_empty_category_view
)
.text = (item as PlaceholderText).text
ItemType.EMOJI -> {
(viewHolder as EmojiViewHolder).bindEmoji((item as EmojiViewData).emoji)
}
}
}
override fun getItemId(position: Int): Long =
emojiPickerItemsProvider().getBodyItem(position).hashCode().toLong()
override fun getItemCount(): Int {
return emojiPickerItemsProvider().size
}
override fun getItemViewType(position: Int): Int {
return emojiPickerItemsProvider().getBodyItem(position).viewType
}
private fun getParentWidth(parent: ViewGroup): Int {
return parent.measuredWidth - parent.paddingLeft - parent.paddingRight
}
private fun getEmojiCellTotalHeight(parent: ViewGroup) =
parent.measuredHeight -
context.resources.getDimensionPixelSize(R.dimen.emoji_picker_category_name_height) * 2 -
context.resources.getDimensionPixelSize(R.dimen.emoji_picker_category_name_padding_top)
private fun createSimpleHolder(
@LayoutRes layoutId: Int,
parent: ViewGroup,
init: (View.() -> Unit)? = null,
) =
object :
ViewHolder(
layoutInflater.inflate(layoutId, parent, /* attachToRoot= */ false).also {
it.layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
init?.invoke(it)
}
) {}
}

View File

@ -0,0 +1,35 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
/** A utility class to hold various constants used by the Emoji Picker library. */
internal object EmojiPickerConstants {
// The default number of body columns.
const val DEFAULT_BODY_COLUMNS = 9
// The default number of rows of recent items held.
const val DEFAULT_MAX_RECENT_ITEM_ROWS = 3
// The max pool size of the Emoji ItemType in RecyclerViewPool.
const val EMOJI_VIEW_POOL_SIZE = 100
const val ADD_VIEW_EXCEPTION_MESSAGE = "Adding views to the EmojiPickerView is unsupported"
const val REMOVE_VIEW_EXCEPTION_MESSAGE =
"Removing views from the EmojiPickerView is unsupported"
}

View File

@ -0,0 +1,88 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.widget.ImageView
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
/** RecyclerView adapter for emoji header. */
internal class EmojiPickerHeaderAdapter(
context: Context,
private val emojiPickerItems: EmojiPickerItems,
private val onHeaderIconClicked: (Int) -> Unit,
) : Adapter<ViewHolder>() {
private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
var selectedGroupIndex: Int = 0
set(value) {
if (value == field) return
notifyItemChanged(field)
notifyItemChanged(value)
field = value
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return object :
ViewHolder(
layoutInflater.inflate(
R.layout.header_icon_holder,
parent,
/* attachToRoot = */ false
)
) {}
}
override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) {
val isItemSelected = i == selectedGroupIndex
val headerIcon =
ViewCompat.requireViewById<ImageView>(
viewHolder.itemView,
R.id.emoji_picker_header_icon
)
.apply {
setImageDrawable(context.getDrawable(emojiPickerItems.getHeaderIconId(i)))
isSelected = isItemSelected
contentDescription = emojiPickerItems.getHeaderIconDescription(i)
}
viewHolder.itemView.setOnClickListener {
onHeaderIconClicked(i)
selectedGroupIndex = i
}
if (isItemSelected) {
headerIcon.post {
headerIcon.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER)
}
}
ViewCompat.requireViewById<View>(viewHolder.itemView, R.id.emoji_picker_header_underline)
.apply {
visibility = if (isItemSelected) View.VISIBLE else View.GONE
isSelected = isItemSelected
}
}
override fun getItemCount(): Int {
return emojiPickerItems.numGroups
}
}

View File

@ -0,0 +1,111 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import androidx.annotation.DrawableRes
import androidx.annotation.IntRange
/**
* A group of items in RecyclerView for emoji picker body. [titleItem] comes first. [contentItems]
* comes after [titleItem]. [emptyPlaceholderItem] will be served after [titleItem] only if
* [contentItems] is empty. [maxContentItemCount], if provided, will truncate [contentItems] to
* certain size.
*
* [categoryIconId] is the corresponding category icon in emoji picker header.
*/
internal class ItemGroup(
@DrawableRes internal val categoryIconId: Int,
internal val titleItem: CategoryTitle,
private val contentItems: List<EmojiViewData>,
private val maxContentItemCount: Int? = null,
private val emptyPlaceholderItem: PlaceholderText? = null
) {
val size: Int
get() =
1 /* title */ +
when {
contentItems.isEmpty() -> if (emptyPlaceholderItem != null) 1 else 0
maxContentItemCount != null && contentItems.size > maxContentItemCount ->
maxContentItemCount
else -> contentItems.size
}
operator fun get(index: Int): ItemViewData {
if (index == 0) return titleItem
val contentIndex = index - 1
if (contentIndex < contentItems.size) return contentItems[contentIndex]
if (contentIndex == 0 && emptyPlaceholderItem != null) return emptyPlaceholderItem
throw IndexOutOfBoundsException()
}
fun getAll(): List<ItemViewData> = IntRange(0, size - 1).map { get(it) }
}
/** A view of concatenated list of [ItemGroup]. */
internal class EmojiPickerItems(
private val groups: List<ItemGroup>,
) : Iterable<ItemViewData> {
val size: Int
get() = groups.sumOf { it.size }
init {
check(groups.isNotEmpty()) { "Initialized with empty categorized sources" }
}
fun getBodyItem(@IntRange(from = 0) absolutePosition: Int): ItemViewData {
var localPosition = absolutePosition
for (group in groups) {
if (localPosition < group.size) return group[localPosition]
else localPosition -= group.size
}
throw IndexOutOfBoundsException()
}
val numGroups: Int
get() = groups.size
@DrawableRes
fun getHeaderIconId(@IntRange(from = 0) index: Int): Int = groups[index].categoryIconId
fun getHeaderIconDescription(@IntRange(from = 0) index: Int): String =
groups[index].titleItem.title
fun groupIndexByItemPosition(@IntRange(from = 0) absolutePosition: Int): Int {
var localPosition = absolutePosition
var index = 0
for (group in groups) {
if (localPosition < group.size) return index
else {
localPosition -= group.size
index++
}
}
throw IndexOutOfBoundsException()
}
fun firstItemPositionByGroupIndex(@IntRange(from = 0) groupIndex: Int): Int =
groups.take(groupIndex).sumOf { it.size }
fun groupRange(group: ItemGroup): kotlin.ranges.IntRange {
check(groups.contains(group))
val index = groups.indexOf(group)
return firstItemPositionByGroupIndex(index).let { it until it + group.size }
}
override fun iterator(): Iterator<ItemViewData> = groups.flatMap { it.getAll() }.iterator()
}

View File

@ -0,0 +1,110 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatImageView
/** Emoji picker popup view with bidirectional UI design to switch emoji to face left or right. */
internal class EmojiPickerPopupBidirectionalDesign(
override val context: Context,
override val targetEmojiView: View,
override val variants: List<String>,
override val popupView: LinearLayout,
override val emojiViewOnClickListener: View.OnClickListener
) : EmojiPickerPopupDesign() {
private var emojiFacingLeft = true
init {
updateTemplate()
}
override fun addLayoutHeader() {
val row =
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
layoutParams =
LinearLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
FrameLayout.inflate(context, R.layout.emoji_picker_popup_bidirectional, row)
.findViewById<AppCompatImageView>(R.id.emoji_picker_popup_bidirectional_icon)
.apply {
layoutParams =
LinearLayout.LayoutParams(targetEmojiView.width, targetEmojiView.height)
}
popupView.addView(row)
val imageView =
row.findViewById<AppCompatImageView>(R.id.emoji_picker_popup_bidirectional_icon)
imageView.setOnClickListener {
emojiFacingLeft = !emojiFacingLeft
updateTemplate()
popupView.removeViews(/* start= */ 1, getActualNumberOfRows())
addRowsToPopupView()
imageView.announceForAccessibility(
context.getString(R.string.emoji_bidirectional_switcher_clicked_desc)
)
}
}
override fun getNumberOfRows(): Int {
// Adding one row for the bidirectional switcher.
return variants.size / 2 / BIDIRECTIONAL_COLUMN_COUNT + 1
}
override fun getNumberOfColumns(): Int {
return BIDIRECTIONAL_COLUMN_COUNT
}
private fun getActualNumberOfRows(): Int {
// Removing one extra row of the bidirectional switcher.
return getNumberOfRows() - 1
}
private fun updateTemplate() {
template =
if (emojiFacingLeft)
arrayOf((variants.indices.filter { it % 12 < 6 }.map { it + 1 }).toIntArray())
else arrayOf((variants.indices.filter { it % 12 >= 6 }.map { it + 1 }).toIntArray())
val row = getActualNumberOfRows()
val column = getNumberOfColumns()
val overrideTemplate = Array(row) { IntArray(column) }
var index = 0
for (i in 0 until row) {
for (j in 0 until column) {
if (index < template[0].size) {
overrideTemplate[i][j] = template[0][index]
index++
}
}
}
template = overrideTemplate
}
companion object {
private const val BIDIRECTIONAL_COLUMN_COUNT = 6
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import android.widget.LinearLayout
/** Emoji picker popup view UI design. Each UI design needs to inherit this abstract class. */
internal abstract class EmojiPickerPopupDesign {
abstract val context: Context
abstract val targetEmojiView: View
abstract val variants: List<String>
abstract val popupView: LinearLayout
abstract val emojiViewOnClickListener: View.OnClickListener
lateinit var template: Array<IntArray>
open fun addLayoutHeader() {
// no-ops
}
open fun addRowsToPopupView() {
for (row in template) {
val rowLayout =
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams =
LinearLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
for (item in row) {
val cell =
if (item == 0) {
EmojiView(context)
} else {
EmojiView(context).apply {
willDrawVariantIndicator = false
emoji = variants[item - 1]
setOnClickListener(emojiViewOnClickListener)
if (item == 1) {
// Hover on the first emoji in the popup
popupView.post {
sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_HOVER_ENTER
)
}
}
}
}
.apply {
layoutParams =
ViewGroup.LayoutParams(
targetEmojiView.width,
targetEmojiView.height
)
}
rowLayout.addView(cell)
}
popupView.addView(rowLayout)
}
}
open fun addLayoutFooter() {
// no-ops
}
abstract fun getNumberOfRows(): Int
abstract fun getNumberOfColumns(): Int
}

View File

@ -0,0 +1,60 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.View
import android.widget.LinearLayout
/** Emoji picker popup view with flat design to list emojis. */
internal class EmojiPickerPopupFlatDesign(
override val context: Context,
override val targetEmojiView: View,
override val variants: List<String>,
override val popupView: LinearLayout,
override val emojiViewOnClickListener: View.OnClickListener
) : EmojiPickerPopupDesign() {
init {
template = arrayOf(variants.indices.map { it + 1 }.toIntArray())
var row = getNumberOfRows()
var column = getNumberOfColumns()
val overrideTemplate = Array(row) { IntArray(column) }
var index = 0
for (i in 0 until row) {
for (j in 0 until column) {
if (index < template[0].size) {
overrideTemplate[i][j] = template[0][index]
index++
}
}
}
template = overrideTemplate
}
override fun getNumberOfRows(): Int {
val column = getNumberOfColumns()
return variants.size / column + if (variants.size % column == 0) 0 else 1
}
override fun getNumberOfColumns(): Int {
return minOf(FLAT_COLUMN_MAX_COUNT, template[0].size)
}
companion object {
private const val FLAT_COLUMN_MAX_COUNT = 6
}
}

View File

@ -0,0 +1,400 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.util.Log
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.annotation.StringRes
import androidx.core.content.res.ResourcesCompat
import com.google.common.collect.ImmutableMap
import com.google.common.primitives.ImmutableIntArray
/** Emoji picker popup with multi-skintone selection panel. */
internal class EmojiPickerPopupMultiSkintoneDesign(
override val context: Context,
override val targetEmojiView: View,
override val variants: List<String>,
override val popupView: LinearLayout,
override val emojiViewOnClickListener: View.OnClickListener,
targetEmoji: String
) : EmojiPickerPopupDesign() {
private val inflater = LayoutInflater.from(context)
private val resultRow =
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams =
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
private var selectedLeftSkintone = -1
private var selectedRightSkintone = -1
init {
val triggerVariantIndex: Int = variants.indexOf(targetEmoji)
if (triggerVariantIndex > 0) {
selectedLeftSkintone = (triggerVariantIndex - 1) / getNumberOfColumns()
selectedRightSkintone =
triggerVariantIndex - selectedLeftSkintone * getNumberOfColumns() - 1
}
}
override fun addRowsToPopupView() {
for (row in 0 until getActualNumberOfRows()) {
val rowLayout =
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams =
LinearLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
for (column in 0 until getNumberOfColumns()) {
inflater.inflate(R.layout.emoji_picker_popup_image_view, rowLayout)
val imageView = rowLayout.getChildAt(column) as ImageView
imageView.apply {
layoutParams =
LinearLayout.LayoutParams(targetEmojiView.width, targetEmojiView.height)
isClickable = true
contentDescription = getImageContentDescription(context, row, column)
if (
(hasLeftSkintone() && row == 0 && selectedLeftSkintone == column) ||
(hasRightSkintone() && row == 1 && selectedRightSkintone == column)
) {
isSelected = true
isClickable = false
}
setImageDrawable(getDrawableRes(context, row, column))
setOnClickListener {
var unSelectedView: View? = null
if (row == 0) {
if (hasLeftSkintone()) {
unSelectedView = rowLayout.getChildAt(selectedLeftSkintone)
}
selectedLeftSkintone = column
} else {
if (hasRightSkintone()) {
unSelectedView = rowLayout.getChildAt(selectedRightSkintone)
}
selectedRightSkintone = column
}
if (unSelectedView != null) {
unSelectedView.isSelected = false
unSelectedView.isClickable = true
}
isClickable = false
isSelected = true
processResultView()
}
}
}
popupView.addView(rowLayout)
}
}
private fun processResultView() {
val childCount = resultRow.childCount
if (childCount < 1 || childCount > 2) {
Log.e(TAG, "processResultEmojiForRectangleLayout(): unexpected emoji result row size")
return
}
// Remove the result emoji if it's already available. It will be available after the row is
// inflated the first time.
if (childCount == 2) {
resultRow.removeViewAt(1)
}
if (hasLeftSkintone() && hasRightSkintone()) {
inflater.inflate(R.layout.emoji_picker_popup_emoji_view, resultRow)
val layout = resultRow.getChildAt(1) as LinearLayout
layout.findViewById<EmojiView>(R.id.emoji_picker_popup_emoji_view).apply {
willDrawVariantIndicator = false
isClickable = true
emoji =
variants[
selectedLeftSkintone * getNumberOfColumns() + selectedRightSkintone + 1]
setOnClickListener(emojiViewOnClickListener)
layoutParams =
LinearLayout.LayoutParams(targetEmojiView.width, targetEmojiView.height)
}
layout.findViewById<LinearLayout>(R.id.emoji_picker_popup_emoji_view_wrapper).apply {
layoutParams =
LinearLayout.LayoutParams(
targetEmojiView.width * getNumberOfColumns() / 2,
targetEmojiView.height
)
}
} else if (hasLeftSkintone()) {
drawImageView(
/* row= */ 0,
/*column=*/ selectedLeftSkintone,
/* applyGrayTint= */ false
)
} else if (hasRightSkintone()) {
drawImageView(
/* row= */ 1,
/*column=*/ selectedRightSkintone,
/* applyGrayTint= */ false
)
} else {
drawImageView(/* row= */ 0, /* column= */ 0, /* applyGrayTint= */ true)
}
}
private fun drawImageView(row: Int, column: Int, applyGrayTint: Boolean) {
inflater
.inflate(R.layout.emoji_picker_popup_image_view, resultRow)
.findViewById<ImageView>(R.id.emoji_picker_popup_image_view)
.apply {
layoutParams = LinearLayout.LayoutParams(0, targetEmojiView.height, 1f)
setImageDrawable(getDrawableRes(context, row, column))
if (applyGrayTint) {
imageTintList = ColorStateList.valueOf(Color.GRAY)
}
var contentDescriptionRow = selectedLeftSkintone
var contentDescriptionColumn = selectedRightSkintone
if (hasLeftSkintone()) {
contentDescriptionRow = 0
contentDescriptionColumn = selectedLeftSkintone
} else if (hasRightSkintone()) {
contentDescriptionRow = 1
contentDescriptionColumn = selectedRightSkintone
}
contentDescription =
getImageContentDescription(
context,
contentDescriptionRow,
contentDescriptionColumn
)
}
}
override fun addLayoutFooter() {
inflater.inflate(R.layout.emoji_picker_popup_emoji_view, resultRow)
val layout = resultRow.getChildAt(0) as LinearLayout
layout.findViewById<EmojiView>(R.id.emoji_picker_popup_emoji_view).apply {
willDrawVariantIndicator = false
emoji = variants[0]
layoutParams = LinearLayout.LayoutParams(targetEmojiView.width, targetEmojiView.height)
isClickable = true
setOnClickListener(emojiViewOnClickListener)
}
layout.findViewById<LinearLayout>(R.id.emoji_picker_popup_emoji_view_wrapper).apply {
layoutParams =
LinearLayout.LayoutParams(
targetEmojiView.width * getNumberOfColumns() / 2,
targetEmojiView.height
)
}
processResultView()
popupView.addView(resultRow)
}
override fun getNumberOfRows(): Int {
// Add one extra row for the neutral skin tone combination
return LAYOUT_ROWS + 1
}
override fun getNumberOfColumns(): Int {
return LAYOUT_COLUMNS
}
private fun getActualNumberOfRows(): Int {
return LAYOUT_ROWS
}
private fun hasLeftSkintone(): Boolean {
return selectedLeftSkintone != -1
}
private fun hasRightSkintone(): Boolean {
return selectedRightSkintone != -1
}
private fun getDrawableRes(context: Context, row: Int, column: Int): Drawable? {
val resArray: ImmutableIntArray? = SKIN_TONES_EMOJI_TO_RESOURCES[variants[0]]
if (resArray != null) {
val contextThemeWrapper = ContextThemeWrapper(context, VARIANT_STYLES[column])
return ResourcesCompat.getDrawable(
context.resources,
resArray[row],
contextThemeWrapper.getTheme()
)
}
return null
}
private fun getImageContentDescription(context: Context, row: Int, column: Int): String {
return context.getString(
R.string.emoji_variant_content_desc_template,
context.getString(getSkintoneStringRes(/* isLeft= */ true, row, column)),
context.getString(getSkintoneStringRes(/* isLeft= */ false, row, column))
)
}
@StringRes
private fun getSkintoneStringRes(isLeft: Boolean, row: Int, column: Int): Int {
// When there is no column, the selected position -1 will be passed in as column.
if (column == -1) {
return R.string.emoji_skin_tone_shadow_content_desc
}
return if (isLeft) {
if (row == 0) SKIN_TONE_CONTENT_DESC_RES_IDS[column]
else R.string.emoji_skin_tone_shadow_content_desc
} else {
if (row == 0) R.string.emoji_skin_tone_shadow_content_desc
else SKIN_TONE_CONTENT_DESC_RES_IDS[column]
}
}
companion object {
private const val TAG = "MultiSkintoneDesign"
private const val LAYOUT_ROWS = 2
private const val LAYOUT_COLUMNS = 5
private val SKIN_TONE_CONTENT_DESC_RES_IDS =
ImmutableIntArray.of(
R.string.emoji_skin_tone_light_content_desc,
R.string.emoji_skin_tone_medium_light_content_desc,
R.string.emoji_skin_tone_medium_content_desc,
R.string.emoji_skin_tone_medium_dark_content_desc,
R.string.emoji_skin_tone_dark_content_desc
)
private val VARIANT_STYLES =
ImmutableIntArray.of(
R.style.EmojiSkintoneSelectorLight,
R.style.EmojiSkintoneSelectorMediumLight,
R.style.EmojiSkintoneSelectorMedium,
R.style.EmojiSkintoneSelectorMediumDark,
R.style.EmojiSkintoneSelectorDark
)
/**
* Map from emoji that use the square layout strategy with skin tone swatches or rectangle
* strategy to their resources.
*/
private val SKIN_TONES_EMOJI_TO_RESOURCES =
ImmutableMap.Builder<String, ImmutableIntArray>()
.put(
"🤝",
ImmutableIntArray.of(
R.drawable.handshake_skintone_shadow,
R.drawable.handshake_shadow_skintone
)
)
.put(
"👭",
ImmutableIntArray.of(
R.drawable.holding_women_skintone_shadow,
R.drawable.holding_women_shadow_skintone
)
)
.put(
"👫",
ImmutableIntArray.of(
R.drawable.holding_woman_man_skintone_shadow,
R.drawable.holding_woman_man_shadow_skintone
)
)
.put(
"👬",
ImmutableIntArray.of(
R.drawable.holding_men_skintone_shadow,
R.drawable.holding_men_shadow_skintone
)
)
.put(
"🧑‍🤝‍🧑",
ImmutableIntArray.of(
R.drawable.holding_people_skintone_shadow,
R.drawable.holding_people_shadow_skintone
)
)
.put(
"💏",
ImmutableIntArray.of(
R.drawable.kiss_people_skintone_shadow,
R.drawable.kiss_people_shadow_skintone
)
)
.put(
"👩‍❤️‍💋‍👨",
ImmutableIntArray.of(
R.drawable.kiss_woman_man_skintone_shadow,
R.drawable.kiss_woman_man_shadow_skintone
)
)
.put(
"👨‍❤️‍💋‍👨",
ImmutableIntArray.of(
R.drawable.kiss_men_skintone_shadow,
R.drawable.kiss_men_shadow_skintone
)
)
.put(
"👩‍❤️‍💋‍👩",
ImmutableIntArray.of(
R.drawable.kiss_women_skintone_shadow,
R.drawable.kiss_women_shadow_skintone
)
)
.put(
"💑",
ImmutableIntArray.of(
R.drawable.couple_heart_people_skintone_shadow,
R.drawable.couple_heart_people_shadow_skintone
)
)
.put(
"👩‍❤️‍👨",
ImmutableIntArray.of(
R.drawable.couple_heart_woman_man_skintone_shadow,
R.drawable.couple_heart_woman_man_shadow_skintone
)
)
.put(
"👨‍❤️‍👨",
ImmutableIntArray.of(
R.drawable.couple_heart_men_skintone_shadow,
R.drawable.couple_heart_men_shadow_skintone
)
)
.put(
"👩‍❤️‍👩",
ImmutableIntArray.of(
R.drawable.couple_heart_women_skintone_shadow,
R.drawable.couple_heart_women_shadow_skintone
)
)
.buildOrThrow()
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.View
import android.widget.LinearLayout
/** Emoji picker popup view with square design. */
internal class EmojiPickerPopupSquareDesign(
override val context: Context,
override val targetEmojiView: View,
override val variants: List<String>,
override val popupView: LinearLayout,
override val emojiViewOnClickListener: View.OnClickListener
) : EmojiPickerPopupDesign() {
init {
template = SQUARE_LAYOUT_TEMPLATE
}
override fun getNumberOfRows(): Int {
return SQUARE_LAYOUT_TEMPLATE.size
}
override fun getNumberOfColumns(): Int {
return SQUARE_LAYOUT_TEMPLATE[0].size
}
companion object {
/**
* Square variant layout template without skin tone. 0 : a place holder Positive number is
* the index + 1 in the variant array
*/
private val SQUARE_LAYOUT_TEMPLATE =
arrayOf(
intArrayOf(0, 2, 3, 4, 5, 6),
intArrayOf(0, 7, 8, 9, 10, 11),
intArrayOf(0, 12, 13, 14, 15, 16),
intArrayOf(0, 17, 18, 19, 20, 21),
intArrayOf(1, 22, 23, 24, 25, 26)
)
}
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
/** Popup view for emoji picker to show emoji variants. */
internal class EmojiPickerPopupView
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int = 0,
private val targetEmojiView: View,
private val targetEmojiItem: EmojiViewItem,
private val emojiViewOnClickListener: OnClickListener
) : FrameLayout(context, attrs, defStyleAttr) {
private val variants = targetEmojiItem.variants
private val targetEmoji = targetEmojiItem.emoji
private val popupView: LinearLayout
private val popupDesign: EmojiPickerPopupDesign
init {
popupView =
inflate(context, R.layout.variant_popup, /* root= */ null)
.findViewById<LinearLayout>(R.id.variant_popup)
val layout = getLayout()
popupDesign =
when (layout) {
Layout.FLAT ->
EmojiPickerPopupFlatDesign(
context,
targetEmojiView,
variants,
popupView,
emojiViewOnClickListener
)
Layout.SQUARE ->
EmojiPickerPopupSquareDesign(
context,
targetEmojiView,
variants,
popupView,
emojiViewOnClickListener
)
Layout.SQUARE_WITH_SKIN_TONE_CIRCLE ->
EmojiPickerPopupMultiSkintoneDesign(
context,
targetEmojiView,
variants,
popupView,
emojiViewOnClickListener,
targetEmoji
)
Layout.BIDIRECTIONAL ->
EmojiPickerPopupBidirectionalDesign(
context,
targetEmojiView,
variants,
popupView,
emojiViewOnClickListener
)
}
popupDesign.addLayoutHeader()
popupDesign.addRowsToPopupView()
popupDesign.addLayoutFooter()
addView(popupView)
}
fun getPopupViewWidth(): Int {
return popupDesign.getNumberOfColumns() * targetEmojiView.width +
popupView.paddingStart +
popupView.paddingEnd
}
fun getPopupViewHeight(): Int {
return popupDesign.getNumberOfRows() * targetEmojiView.height +
popupView.paddingTop +
popupView.paddingBottom
}
private fun getLayout(): Layout {
if (variants.size == SQUARE_LAYOUT_VARIANT_COUNT)
if (SQUARE_LAYOUT_EMOJI_NO_SKIN_TONE.contains(variants[0])) return Layout.SQUARE
else return Layout.SQUARE_WITH_SKIN_TONE_CIRCLE
else if (variants.size == BIDIRECTIONAL_VARIANTS_COUNT) return Layout.BIDIRECTIONAL
else return Layout.FLAT
}
companion object {
private enum class Layout {
FLAT,
SQUARE,
SQUARE_WITH_SKIN_TONE_CIRCLE,
BIDIRECTIONAL
}
/**
* The number of variants expected when using a square layout strategy. Square layouts are
* comprised of a 5x5 grid + the base variant.
*/
private const val SQUARE_LAYOUT_VARIANT_COUNT = 26
/**
* The number of variants expected when using a bidirectional layout strategy. Bidirectional
* layouts are comprised of bidirectional icon and a 3x6 grid with left direction emojis as
* default. After clicking the bidirectional icon, it switches to a bidirectional icon and a
* 3x6 grid with right direction emojis.
*/
private const val BIDIRECTIONAL_VARIANTS_COUNT = 36
// Set of emojis that use the square layout without skin tone swatches.
private val SQUARE_LAYOUT_EMOJI_NO_SKIN_TONE = setOf("👪")
}
}

View File

@ -0,0 +1,84 @@
package androidx.emoji2.emojipicker
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.WindowManager
import android.widget.PopupWindow
import android.widget.Toast
import kotlin.math.roundToInt
/**
* Default controller class for emoji picker popup view.
*
* <p>Shows the popup view above the target Emoji. View under control is a {@code
* EmojiPickerPopupView}.
*/
internal class EmojiPickerPopupViewController(
private val context: Context,
private val emojiPickerPopupView: EmojiPickerPopupView,
private val clickedEmojiView: View
) {
private val popupWindow: PopupWindow =
PopupWindow(
emojiPickerPopupView,
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
/* focusable= */ false
)
fun show() {
popupWindow.apply {
val location = IntArray(2)
clickedEmojiView.getLocationInWindow(location)
// Make the popup view center align with the target emoji view.
val x =
location[0] + clickedEmojiView.width / 2f -
emojiPickerPopupView.getPopupViewWidth() / 2f
val y = location[1] - emojiPickerPopupView.getPopupViewHeight()
// Set background drawable so that the popup window is dismissed properly when clicking
// outside / scrolling for API < 23.
setBackgroundDrawable(context.getDrawable(R.drawable.popup_view_rounded_background))
isOutsideTouchable = true
isTouchable = true
animationStyle = R.style.VariantPopupAnimation
elevation =
clickedEmojiView.context.resources
.getDimensionPixelSize(R.dimen.emoji_picker_popup_view_elevation)
.toFloat()
try {
showAtLocation(clickedEmojiView, Gravity.NO_GRAVITY, x.roundToInt(), y)
} catch (e: WindowManager.BadTokenException) {
Toast.makeText(
context,
"Don't use EmojiPickerView inside a Popup",
Toast.LENGTH_LONG
)
.show()
}
}
}
fun dismiss() {
if (popupWindow.isShowing) {
popupWindow.dismiss()
}
}
}

View File

@ -0,0 +1,460 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.util.Consumer
import androidx.core.view.ViewCompat
import androidx.emoji2.emojipicker.EmojiPickerConstants.DEFAULT_MAX_RECENT_ITEM_ROWS
import androidx.emoji2.text.EmojiCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The emoji picker view that provides up-to-date emojis in a vertical scrollable view with a
* clickable horizontal header.
*/
class EmojiPickerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
internal companion object {
internal var emojiCompatLoaded: Boolean = false
}
private var _emojiGridRows: Float? = null
/**
* The number of rows of the emoji picker.
*
* Optional field. If not set, the value will be calculated based on parent view height and
* [emojiGridColumns]. Float value indicates that the picker could display the last row
* partially, so the users get the idea that they can scroll down for more contents.
*
* @attr ref androidx.emoji2.emojipicker.R.styleable.EmojiPickerView_emojiGridRows
*/
var emojiGridRows: Float
get() = _emojiGridRows ?: -1F
set(value) {
_emojiGridRows = value.takeIf { it > 0 }
// Refresh when emojiGridRows is reset
if (isLaidOut) {
showEmojiPickerView()
}
}
/**
* The number of columns of the emoji picker.
*
* Default value([EmojiPickerConstants.DEFAULT_BODY_COLUMNS]: 9) will be used if
* emojiGridColumns is set to non-positive value.
*
* @attr ref androidx.emoji2.emojipicker.R.styleable.EmojiPickerView_emojiGridColumns
*/
var emojiGridColumns: Int = EmojiPickerConstants.DEFAULT_BODY_COLUMNS
set(value) {
field = value.takeIf { it > 0 } ?: EmojiPickerConstants.DEFAULT_BODY_COLUMNS
// Refresh when emojiGridColumns is reset
if (isLaidOut) {
showEmojiPickerView()
}
}
private val stickyVariantProvider = StickyVariantProvider(context)
private val scope = CoroutineScope(EmptyCoroutineContext)
private var recentEmojiProvider: RecentEmojiProvider = DefaultRecentEmojiProvider(context)
private var recentNeedsRefreshing: Boolean = true
private val recentItems: MutableList<EmojiViewData> = mutableListOf()
private lateinit var recentItemGroup: ItemGroup
private lateinit var emojiPickerItems: EmojiPickerItems
private lateinit var bodyAdapter: EmojiPickerBodyAdapter
private var onEmojiPickedListener: Consumer<EmojiViewItem>? = null
init {
val typedArray: TypedArray =
context.obtainStyledAttributes(attrs, R.styleable.EmojiPickerView, 0, 0)
_emojiGridRows =
with(R.styleable.EmojiPickerView_emojiGridRows) {
if (typedArray.hasValue(this)) {
typedArray.getFloat(this, 0F)
} else null
}
emojiGridColumns =
typedArray.getInt(
R.styleable.EmojiPickerView_emojiGridColumns,
EmojiPickerConstants.DEFAULT_BODY_COLUMNS
)
typedArray.recycle()
if (EmojiCompat.isConfigured()) {
when (EmojiCompat.get().loadState) {
EmojiCompat.LOAD_STATE_SUCCEEDED -> emojiCompatLoaded = true
EmojiCompat.LOAD_STATE_LOADING,
EmojiCompat.LOAD_STATE_DEFAULT ->
EmojiCompat.get()
.registerInitCallback(
object : EmojiCompat.InitCallback() {
override fun onInitialized() {
emojiCompatLoaded = true
scope.launch(Dispatchers.IO) {
BundledEmojiListLoader.load(context)
withContext(Dispatchers.Main) {
emojiPickerItems = buildEmojiPickerItems()
bodyAdapter.notifyDataSetChanged()
}
}
}
override fun onFailed(throwable: Throwable?) {}
}
)
}
}
scope.launch(Dispatchers.IO) {
val load = launch { BundledEmojiListLoader.load(context) }
refreshRecent()
load.join()
withContext(Dispatchers.Main) { showEmojiPickerView() }
}
}
private fun createEmojiPickerBodyAdapter(): EmojiPickerBodyAdapter {
return EmojiPickerBodyAdapter(
context,
emojiGridColumns,
_emojiGridRows,
stickyVariantProvider,
emojiPickerItemsProvider = { emojiPickerItems },
onEmojiPickedListener = { emojiViewItem ->
onEmojiPickedListener?.accept(emojiViewItem)
recentEmojiProvider.recordSelection(emojiViewItem.emoji)
recentNeedsRefreshing = true
}
)
}
internal fun buildEmojiPickerItems() =
EmojiPickerItems(
buildList {
add(
ItemGroup(
R.drawable.quantum_gm_ic_access_time_filled_vd_theme_24,
CategoryTitle(context.getString(R.string.emoji_category_recent)),
recentItems,
maxContentItemCount = DEFAULT_MAX_RECENT_ITEM_ROWS * emojiGridColumns,
emptyPlaceholderItem =
PlaceholderText(
context.getString(R.string.emoji_empty_recent_category)
)
)
.also { recentItemGroup = it }
)
for ((i, category) in
BundledEmojiListLoader.getCategorizedEmojiData().withIndex()) {
add(
ItemGroup(
category.headerIconId,
CategoryTitle(category.categoryName),
category.emojiDataList.mapIndexed { j, emojiData ->
EmojiViewData(
stickyVariantProvider[emojiData.emoji],
dataIndex = i + j
)
},
)
)
}
}
)
private fun showEmojiPickerView() {
emojiPickerItems = buildEmojiPickerItems()
val bodyLayoutManager =
GridLayoutManager(
context,
emojiGridColumns,
LinearLayoutManager.VERTICAL,
/* reverseLayout = */ false
)
.apply {
spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (emojiPickerItems.getBodyItem(position).itemType) {
ItemType.CATEGORY_TITLE,
ItemType.PLACEHOLDER_TEXT -> emojiGridColumns
else -> 1
}
}
}
}
val headerAdapter =
EmojiPickerHeaderAdapter(
context,
emojiPickerItems,
onHeaderIconClicked = {
with(emojiPickerItems.firstItemPositionByGroupIndex(it)) {
if (this == emojiPickerItems.groupRange(recentItemGroup).first) {
scope.launch { refreshRecent() }
}
bodyLayoutManager.scrollToPositionWithOffset(this, 0)
// The scroll position change will not be reflected until the next layout
// call,
// so force a new layout call here.
invalidate()
}
}
)
// clear view's children in case of resetting layout
super.removeAllViews()
with(inflate(context, R.layout.emoji_picker, this)) {
// set headerView
ViewCompat.requireViewById<RecyclerView>(this, R.id.emoji_picker_header).apply {
layoutManager =
object : LinearLayoutManager(context, HORIZONTAL, /* reverseLayout= */ false) {
override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean {
lp.width =
(width - paddingStart - paddingEnd) / emojiPickerItems.numGroups
return true
}
}
adapter = headerAdapter
}
// set bodyView
ViewCompat.requireViewById<RecyclerView>(this, R.id.emoji_picker_body).apply {
layoutManager = bodyLayoutManager
adapter =
createEmojiPickerBodyAdapter()
.apply { setHasStableIds(true) }
.also { bodyAdapter = it }
addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
headerAdapter.selectedGroupIndex =
emojiPickerItems.groupIndexByItemPosition(
bodyLayoutManager.findFirstCompletelyVisibleItemPosition()
)
if (
recentNeedsRefreshing &&
bodyLayoutManager.findFirstVisibleItemPosition() !in
emojiPickerItems.groupRange(recentItemGroup)
) {
scope.launch { refreshRecent() }
}
}
}
)
// Disable item insertion/deletion animation. This keeps view holder unchanged when
// item updates.
itemAnimator = null
setRecycledViewPool(
RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(
ItemType.EMOJI.ordinal,
EmojiPickerConstants.EMOJI_VIEW_POOL_SIZE
)
}
)
}
}
}
internal suspend fun refreshRecent() {
if (!recentNeedsRefreshing) {
return
}
val oldGroupSize = if (::recentItemGroup.isInitialized) recentItemGroup.size else 0
val recent = recentEmojiProvider.getRecentEmojiList()
withContext(Dispatchers.Main) {
recentItems.clear()
recentItems.addAll(
recent.map {
EmojiViewData(
it,
updateToSticky = false,
)
}
)
if (::emojiPickerItems.isInitialized) {
val range = emojiPickerItems.groupRange(recentItemGroup)
if (recentItemGroup.size > oldGroupSize) {
bodyAdapter.notifyItemRangeInserted(
range.first + oldGroupSize,
recentItemGroup.size - oldGroupSize
)
} else if (recentItemGroup.size < oldGroupSize) {
bodyAdapter.notifyItemRangeRemoved(
range.first + recentItemGroup.size,
oldGroupSize - recentItemGroup.size
)
}
bodyAdapter.notifyItemRangeChanged(
range.first,
minOf(oldGroupSize, recentItemGroup.size)
)
recentNeedsRefreshing = false
}
}
}
/**
* This function is used to set the custom behavior after clicking on an emoji icon. Clients
* could specify their own behavior inside this function.
*/
fun setOnEmojiPickedListener(onEmojiPickedListener: Consumer<EmojiViewItem>?) {
this.onEmojiPickedListener = onEmojiPickedListener
}
fun setRecentEmojiProvider(recentEmojiProvider: RecentEmojiProvider) {
this.recentEmojiProvider = recentEmojiProvider
scope.launch {
recentNeedsRefreshing = true
refreshRecent()
}
}
/**
* The following functions disallow clients to add view to the EmojiPickerView
*
* @param child the child view to be added
* @throws UnsupportedOperationException
*/
override fun addView(child: View?) {
if (childCount > 0)
throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
else super.addView(child)
}
/**
* @param child
* @param params
* @throws UnsupportedOperationException
*/
override fun addView(child: View?, params: ViewGroup.LayoutParams?) {
if (childCount > 0)
throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
else super.addView(child, params)
}
/**
* @param child
* @param index
* @throws UnsupportedOperationException
*/
override fun addView(child: View?, index: Int) {
if (childCount > 0)
throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
else super.addView(child, index)
}
/**
* @param child
* @param index
* @param params
* @throws UnsupportedOperationException
*/
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (childCount > 0)
throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
else super.addView(child, index, params)
}
/**
* @param child
* @param width
* @param height
* @throws UnsupportedOperationException
*/
override fun addView(child: View?, width: Int, height: Int) {
if (childCount > 0)
throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
else super.addView(child, width, height)
}
/**
* The following functions disallow clients to remove view from the EmojiPickerView
*
* @throws UnsupportedOperationException
*/
override fun removeAllViews() {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
/**
* @param child
* @throws UnsupportedOperationException
*/
override fun removeView(child: View?) {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
/**
* @param index
* @throws UnsupportedOperationException
*/
override fun removeViewAt(index: Int) {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
/**
* @param child
* @throws UnsupportedOperationException
*/
override fun removeViewInLayout(child: View?) {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
/**
* @param start
* @param count
* @throws UnsupportedOperationException
*/
override fun removeViews(start: Int, count: Int) {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
/**
* @param start
* @param count
* @throws UnsupportedOperationException
*/
override fun removeViewsInLayout(start: Int, count: Int) {
throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
}
}

View File

@ -0,0 +1,187 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.Build
import android.text.Layout
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.graphics.applyCanvas
import androidx.emoji2.text.EmojiCompat
/** A customized view to support drawing emojis asynchronously. */
internal class EmojiView
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
companion object {
private const val EMOJI_DRAW_TEXT_SIZE_SP = 30
}
init {
background = context.getDrawable(R.drawable.ripple_emoji_view)
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
}
internal var willDrawVariantIndicator: Boolean = true
private val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
textSize =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
EMOJI_DRAW_TEXT_SIZE_SP.toFloat(),
context.resources.displayMetrics
)
}
private val offscreenCanvasBitmap: Bitmap =
with(textPaint.fontMetricsInt) {
val size = bottom - top
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size =
minOf(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) -
context.resources.getDimensionPixelSize(R.dimen.emoji_picker_emoji_view_padding)
setMeasuredDimension(size, size)
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
canvas.run {
save()
scale(
width.toFloat() / offscreenCanvasBitmap.width,
height.toFloat() / offscreenCanvasBitmap.height
)
drawBitmap(offscreenCanvasBitmap, 0f, 0f, null)
restore()
}
}
var emoji: CharSequence? = null
set(value) {
field = value
post {
if (value != null) {
if (value == this.emoji) {
drawEmoji(
if (EmojiPickerView.emojiCompatLoaded)
EmojiCompat.get().process(value) ?: value
else value,
drawVariantIndicator =
willDrawVariantIndicator &&
BundledEmojiListLoader.getEmojiVariantsLookup()
.containsKey(value)
)
contentDescription = value
}
invalidate()
} else {
offscreenCanvasBitmap.eraseColor(Color.TRANSPARENT)
}
}
}
private fun drawEmoji(emoji: CharSequence, drawVariantIndicator: Boolean) {
offscreenCanvasBitmap.eraseColor(Color.TRANSPARENT)
offscreenCanvasBitmap.applyCanvas {
if (emoji is Spanned) {
createStaticLayout(emoji, width).draw(this)
} else {
val textWidth = textPaint.measureText(emoji, 0, emoji.length)
drawText(
emoji,
/* start = */ 0,
/* end = */ emoji.length,
/* x = */ (width - textWidth) / 2,
/* y = */ -textPaint.fontMetrics.top,
textPaint,
)
}
if (drawVariantIndicator) {
context
.getDrawable(R.drawable.variant_availability_indicator)
?.apply {
val canvasWidth = this@applyCanvas.width
val canvasHeight = this@applyCanvas.height
val indicatorWidth =
context.resources.getDimensionPixelSize(
R.dimen.variant_availability_indicator_width
)
val indicatorHeight =
context.resources.getDimensionPixelSize(
R.dimen.variant_availability_indicator_height
)
bounds =
Rect(
canvasWidth - indicatorWidth,
canvasHeight - indicatorHeight,
canvasWidth,
canvasHeight
)
}!!
.draw(this)
}
}
}
@RequiresApi(23)
internal object Api23Impl {
fun createStaticLayout(emoji: Spanned, textPaint: TextPaint, width: Int): StaticLayout =
StaticLayout.Builder.obtain(emoji, 0, emoji.length, textPaint, width)
.apply {
setAlignment(Layout.Alignment.ALIGN_CENTER)
setLineSpacing(/* spacingAdd= */ 0f, /* spacingMult= */ 1f)
setIncludePad(false)
}
.build()
}
private fun createStaticLayout(emoji: Spanned, width: Int): StaticLayout {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return Api23Impl.createStaticLayout(emoji, textPaint, width)
} else {
@Suppress("DEPRECATION")
return StaticLayout(
emoji,
textPaint,
width,
Layout.Alignment.ALIGN_CENTER,
/* spacingmult = */ 1f,
/* spacingadd = */ 0f,
/* includepad = */ false,
)
}
}
}

View File

@ -0,0 +1,95 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.view.View
import android.view.View.OnLongClickListener
import android.view.ViewGroup.LayoutParams
import android.view.accessibility.AccessibilityEvent
import androidx.recyclerview.widget.RecyclerView.ViewHolder
/** A [ViewHolder] containing an emoji view and emoji data. */
internal class EmojiViewHolder(
context: Context,
width: Int,
height: Int,
private val stickyVariantProvider: StickyVariantProvider,
private val onEmojiPickedListener: EmojiViewHolder.(EmojiViewItem) -> Unit,
private val onEmojiPickedFromPopupListener: EmojiViewHolder.(String) -> Unit
) : ViewHolder(EmojiView(context)) {
private val onEmojiLongClickListener: OnLongClickListener =
OnLongClickListener { targetEmojiView ->
showEmojiPopup(context, targetEmojiView)
}
private val emojiView: EmojiView =
(itemView as EmojiView).apply {
layoutParams = LayoutParams(width, height)
isClickable = true
setOnClickListener {
it.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
onEmojiPickedListener(emojiViewItem)
}
}
private lateinit var emojiViewItem: EmojiViewItem
private lateinit var emojiPickerPopupViewController: EmojiPickerPopupViewController
fun bindEmoji(
emoji: String,
) {
emojiView.emoji = emoji
emojiViewItem = makeEmojiViewItem(emoji)
if (emojiViewItem.variants.isNotEmpty()) {
emojiView.setOnLongClickListener(onEmojiLongClickListener)
emojiView.isLongClickable = true
} else {
emojiView.setOnLongClickListener(null)
emojiView.isLongClickable = false
}
}
private fun showEmojiPopup(context: Context, clickedEmojiView: View): Boolean {
val emojiPickerPopupView =
EmojiPickerPopupView(
context,
/* attrs= */ null,
targetEmojiView = clickedEmojiView,
targetEmojiItem = emojiViewItem,
emojiViewOnClickListener = { view ->
val emojiPickedInPopup = (view as EmojiView).emoji.toString()
onEmojiPickedFromPopupListener(emojiPickedInPopup)
onEmojiPickedListener(makeEmojiViewItem(emojiPickedInPopup))
// variants[0] is always the base (i.e., primary) emoji
stickyVariantProvider.update(emojiViewItem.variants[0], emojiPickedInPopup)
emojiPickerPopupViewController.dismiss()
// Hover on the base emoji after popup dismissed
clickedEmojiView.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_HOVER_ENTER
)
}
)
emojiPickerPopupViewController =
EmojiPickerPopupViewController(context, emojiPickerPopupView, clickedEmojiView)
emojiPickerPopupViewController.show()
return true
}
private fun makeEmojiViewItem(emoji: String) =
EmojiViewItem(emoji, BundledEmojiListLoader.getEmojiVariantsLookup()[emoji] ?: listOf())
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
/**
* [EmojiViewItem] is a class holding the displayed emoji and its emoji variants
*
* @param emoji Used to represent the displayed emoji of the [EmojiViewItem].
* @param variants Used to represent the corresponding emoji variants of this base emoji.
*/
class EmojiViewItem(val emoji: String, val variants: List<String>)

View File

@ -0,0 +1,46 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
internal enum class ItemType {
CATEGORY_TITLE,
PLACEHOLDER_TEXT,
EMOJI,
}
/** Represents an item within the body RecyclerView. */
internal sealed class ItemViewData(val itemType: ItemType) {
val viewType = itemType.ordinal
}
/** Title of each category. */
internal data class CategoryTitle(val title: String) : ItemViewData(ItemType.CATEGORY_TITLE)
/** Text to display when the category contains no items. */
internal data class PlaceholderText(val text: String) : ItemViewData(ItemType.PLACEHOLDER_TEXT)
/** Represents an emoji. */
internal data class EmojiViewData(
var emoji: String,
val updateToSticky: Boolean = true,
// Needed to ensure uniqueness since we enabled stable Id.
val dataIndex: Int = 0
) : ItemViewData(ItemType.EMOJI)
internal object Extensions {
internal fun Int.toItemType() = ItemType.values()[this]
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.guava.await
/**
* A interface equivalent to [RecentEmojiProvider] that allows java clients to override the
* [ListenableFuture] based function [getRecentEmojiListAsync] in order to provide recent emojis.
*/
interface RecentEmojiAsyncProvider {
fun recordSelection(emoji: String)
fun getRecentEmojiListAsync(): ListenableFuture<List<String>>
}
/** An adapter for the [RecentEmojiAsyncProvider]. */
class RecentEmojiProviderAdapter(private val recentEmojiAsyncProvider: RecentEmojiAsyncProvider) :
RecentEmojiProvider {
override fun recordSelection(emoji: String) {
recentEmojiAsyncProvider.recordSelection(emoji)
}
override suspend fun getRecentEmojiList() =
recentEmojiAsyncProvider.getRecentEmojiListAsync().await()
}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
/** An interface to provide recent emoji list. */
interface RecentEmojiProvider {
/**
* Records an emoji into recent emoji list. This fun will be called when an emoji is selected.
* Clients could specify the behavior to record recently used emojis.(e.g. click frequency).
*/
fun recordSelection(emoji: String)
/**
* Returns a list of recent emojis. Default behavior: The most recently used emojis will be
* displayed first. Clients could also specify the behavior such as displaying the emojis from
* high click frequency to low click frequency.
*/
suspend fun getRecentEmojiList(): List<String>
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker
import android.content.Context
import android.content.Context.MODE_PRIVATE
/** A class that handles user's emoji variant selection using SharedPreferences. */
internal class StickyVariantProvider(context: Context) {
companion object {
const val PREFERENCES_FILE_NAME = "androidx.emoji2.emojipicker.preferences"
const val STICKY_VARIANT_PROVIDER_KEY = "pref_key_sticky_variant"
const val KEY_VALUE_DELIMITER = "="
const val ENTRY_DELIMITER = "|"
}
private val sharedPreferences =
context.getSharedPreferences(PREFERENCES_FILE_NAME, MODE_PRIVATE)
private val stickyVariantMap: MutableMap<String, String> by lazy {
sharedPreferences
.getString(STICKY_VARIANT_PROVIDER_KEY, null)
?.split(ENTRY_DELIMITER)
?.associate { entry ->
entry
.split(KEY_VALUE_DELIMITER, limit = 2)
.takeIf { it.size == 2 }
?.let { it[0] to it[1] } ?: ("" to "")
}
?.toMutableMap() ?: mutableMapOf()
}
internal operator fun get(emoji: String): String = stickyVariantMap[emoji] ?: emoji
internal fun update(baseEmoji: String, variantClicked: String) {
stickyVariantMap.apply {
if (baseEmoji == variantClicked) {
this.remove(baseEmoji)
} else {
this[baseEmoji] = variantClicked
}
sharedPreferences
.edit()
.putString(STICKY_VARIANT_PROVIDER_KEY, entries.joinToString(ENTRY_DELIMITER))
.commit()
}
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker.utils
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.annotation.GuardedBy
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.emoji2.emojipicker.BundledEmojiListLoader
import androidx.emoji2.emojipicker.EmojiViewItem
import java.io.File
import java.io.IOException
/**
* A class that manages cache files for the emoji picker. All cache files are stored in DE (Device
* Encryption) storage (N+), and will be invalidated if device OS version or App version is updated.
*
* Currently this class is only used by [BundledEmojiListLoader]. All renderable emojis will be
* cached by categories under /app.package.name/cache/emoji_picker/<osVersion.appVersion>
* /emoji.<emojiPickerVersion>.<emojiCompatMetadataHashCode>.<categoryIndex>.<ifEmoji12Supported>
*/
internal class FileCache(context: Context) {
@VisibleForTesting @GuardedBy("lock") internal val emojiPickerCacheDir: File
private val currentProperty: String
private val lock = Any()
init {
val osVersion = "${Build.VERSION.SDK_INT}_${Build.TIME}"
val appVersion = getVersionCode(context)
currentProperty = "$osVersion.$appVersion"
emojiPickerCacheDir =
File(getDeviceProtectedStorageContext(context).cacheDir, EMOJI_PICKER_FOLDER)
if (!emojiPickerCacheDir.exists()) emojiPickerCacheDir.mkdir()
}
/** Get cache for a given file name, or write to a new file using the [defaultValue] factory. */
internal fun getOrPut(
key: String,
defaultValue: () -> List<EmojiViewItem>
): List<EmojiViewItem> {
synchronized(lock) {
val targetDir = File(emojiPickerCacheDir, currentProperty)
// No matching cache folder for current property, clear stale cache directory if any
if (!targetDir.exists()) {
emojiPickerCacheDir.listFiles()?.forEach { it.deleteRecursively() }
targetDir.mkdirs()
}
val targetFile = File(targetDir, key)
return readFrom(targetFile) ?: writeTo(targetFile, defaultValue)
}
}
private fun readFrom(targetFile: File): List<EmojiViewItem>? {
if (!targetFile.isFile) return null
return targetFile
.bufferedReader()
.useLines { it.toList() }
.map { it.split(",") }
.map { EmojiViewItem(it.first(), it.drop(1)) }
}
private fun writeTo(
targetFile: File,
defaultValue: () -> List<EmojiViewItem>
): List<EmojiViewItem> {
val data = defaultValue.invoke()
if (targetFile.exists()) {
if (!targetFile.delete()) {
Log.wtf(TAG, "Can't delete file: $targetFile")
}
}
if (!targetFile.createNewFile()) {
throw IOException("Can't create file: $targetFile")
}
targetFile.bufferedWriter().use { out ->
for (emoji in data) {
out.write(emoji.emoji)
emoji.variants.forEach { out.write(",$it") }
out.newLine()
}
}
return data
}
/** Returns a new [context] for accessing device protected storage. */
private fun getDeviceProtectedStorageContext(context: Context) =
context.takeIf { ContextCompat.isDeviceProtectedStorage(it) }
?: run { ContextCompat.createDeviceProtectedStorageContext(context) }
?: context
/** Gets the version code for a package. */
@Suppress("DEPRECATION")
private fun getVersionCode(context: Context): Long =
try {
if (Build.VERSION.SDK_INT >= 33) Api33Impl.getAppVersionCode(context)
else if (Build.VERSION.SDK_INT >= 28) Api28Impl.getAppVersionCode(context)
else context.packageManager.getPackageInfo(context.packageName, 0).versionCode.toLong()
} catch (e: PackageManager.NameNotFoundException) {
// Default version to 1
1
}
companion object {
@Volatile private var instance: FileCache? = null
internal fun getInstance(context: Context): FileCache =
instance ?: synchronized(this) { instance ?: FileCache(context).also { instance = it } }
private const val EMOJI_PICKER_FOLDER = "emoji_picker"
private const val TAG = "emojipicker.FileCache"
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
internal object Api33Impl {
fun getAppVersionCode(context: Context) =
context.packageManager
.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))
.longVersionCode
}
@Suppress("DEPRECATION")
@RequiresApi(Build.VERSION_CODES.P)
internal object Api28Impl {
fun getAppVersionCode(context: Context) =
context.packageManager
.getPackageInfo(context.packageName, /* flags= */ 0)
.longVersionCode
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji2.emojipicker.utils
import android.os.Build
import android.text.TextPaint
import androidx.annotation.VisibleForTesting
import androidx.core.graphics.PaintCompat
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.text.EmojiCompat
/** Checks renderability of unicode characters. */
internal object UnicodeRenderableManager {
private const val VARIATION_SELECTOR = "\uFE0F"
private const val YAWNING_FACE_EMOJI = "\uD83E\uDD71"
private val paint = TextPaint()
/**
* Some emojis were usual (non-emoji) characters. Old devices cannot render them with variation
* selector (U+FE0F) so it's worth trying to check renderability again without variation
* selector.
*/
private val CATEGORY_MOVED_EMOJIS =
listOf( // These three characters have been emoji since Unicode emoji version 4.
// version 3: https://unicode.org/Public/emoji/3.0/emoji-data.txt
// version 4: https://unicode.org/Public/emoji/4.0/emoji-data.txt
"\u2695\uFE0F", // STAFF OF AESCULAPIUS
"\u2640\uFE0F", // FEMALE SIGN
"\u2642\uFE0F", // MALE SIGN
// These three characters have been emoji since Unicode emoji version 11.
// version 5: https://unicode.org/Public/emoji/5.0/emoji-data.txt
// version 11: https://unicode.org/Public/emoji/11.0/emoji-data.txt
"\u265F\uFE0F", // BLACK_CHESS_PAWN
"\u267E\uFE0F" // PERMANENT_PAPER_SIGN
)
/**
* For a given emoji, check it's renderability with EmojiCompat if enabled. Otherwise, use
* [PaintCompat#hasGlyph].
*
* Note: For older API version, codepoints {@code U+0xFE0F} are removed.
*/
internal fun isEmojiRenderable(emoji: String) =
if (EmojiPickerView.emojiCompatLoaded)
EmojiCompat.get().getEmojiMatch(emoji, Int.MAX_VALUE) == EmojiCompat.EMOJI_SUPPORTED
else getClosestRenderable(emoji) != null
// Yawning face is added in emoji 12 which is the first version starts to support gender
// inclusive emojis.
internal fun isEmoji12Supported() = isEmojiRenderable(YAWNING_FACE_EMOJI)
@VisibleForTesting
fun getClosestRenderable(emoji: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return emoji.replace(VARIATION_SELECTOR, "").takeIfHasGlyph()
}
return emoji.takeIfHasGlyph()
?: run {
if (CATEGORY_MOVED_EMOJIS.contains(emoji))
emoji.replace(VARIATION_SELECTOR, "").takeIfHasGlyph()
else null
}
}
private fun String.takeIfHasGlyph() = takeIf { PaintCompat.hasGlyph(paint, this) }
}

Some files were not shown because too many files have changed in this diff Show More