package expo.community.modules.emojisheet import android.content.Context import android.content.SharedPreferences import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.ViewConfiguration import android.view.VelocityTracker import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext class EmojiSheetUIView(context: Context) : LinearLayout(context) { companion object { private const val FREQ_PREFS = "emoji_sheet_frequently_used" private const val FREQ_COUNT_SUFFIX = "_count" private const val FREQ_DAY_SUFFIX = "_day" private const val FREQ_TIME_SUFFIX = "_time" private val cacheScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val cacheMutex = Mutex() @Volatile private var cachedData: Pair, Map>>? = null fun warmCache(context: Context) { val appContext = context.applicationContext cacheScope.launch { appContext.getSharedPreferences(FREQ_PREFS, Context.MODE_PRIVATE).all } if (cachedData != null) return cacheScope.launch { loadCachedData(appContext) } } private suspend fun loadCachedData(context: Context): Pair, Map>> { cachedData?.let { return it } return cacheMutex.withLock { cachedData?.let { return it } try { val categories = EmojiData.loadCategories(context) val keywords = loadAllKeywords(context) Pair(categories, keywords).also { cachedData = it } } catch (e: Exception) { // Degrade to an empty data set instead of throwing an uncaught // coroutine exception (which would crash the app). Not cached, so a // later attempt can still succeed. Mirrors iOS, which returns []. android.util.Log.e("EmojiSheet", "Failed to load emoji data", e) Pair(emptyList(), emptyMap()) } } } private fun loadAllKeywords(context: Context): Map> { val merged = mutableMapOf>() try { val translationFiles = context.assets.list("translations")?.filter { it.endsWith(".json") } ?: emptyList() for (file in translationFiles) { val json = context.assets.open("translations/$file").bufferedReader().use { it.readText() } val obj = org.json.JSONObject(json) for (key in obj.keys()) { val arr = obj.getJSONArray(key) val keywords = merged.getOrPut(key) { mutableListOf() } for (i in 0 until arr.length()) { val normalizedKeyword = EmojiData.normalizeSearchText(arr.getString(i)) if (normalizedKeyword.isNotBlank()) { keywords.add(normalizedKeyword) } } } } } catch (e: Exception) { // Fallback or empty } return merged.mapValues { it.value.distinct() } } } var onEmojiSelected: ((Map) -> Unit)? = null var onSearchFocused: ((Boolean) -> Unit)? = null var onScrollIntentUp: (() -> Unit)? = null var onPullDownAtTopDrag: ((Float) -> Unit)? = null var onPullDownAtTopRelease: ((Float, Float) -> Unit)? = null // Configurable properties var columns: Int = 7 var emojiSize: Float = 32f var showSearch: Boolean = true set(value) { field = value if (!value && currentSearchQuery.isNotBlank()) { searchBar.clearSearch() onSearch("") } } var showRecents: Boolean = true var enableSkinTones: Boolean = true var enableHaptics: Boolean = true var enableAnimations: Boolean = false var recentLimit: Int = 30 var categoryBarPosition: String = "top" var layoutDirectionProp: String = "auto" set(value) { field = value applyLayoutDirection() } var categoryNames: Map? = null @Volatile var excludeEmojis: Set = emptySet() private var currentTheme = EmojiSheetTheme.light private var allCategories: List = emptyList() private var allCategoryKeys: List = emptyList() // Values are normalized once at load time so search scoring only compares strings. private var localizedKeywords: Map> = emptyMap() private val searchBar: EmojiSearchBar private lateinit var categoryStrip: EmojiCategoryStrip private val recyclerView: RecyclerView private val gridAdapter: EmojiGridAdapter private val gridLayoutManager: GridLayoutManager private val stickyHeaderDecoration: StickyHeaderDecoration private val emptyStateLabel: TextView private val contentFrame: FrameLayout private var isSearchActive = false private var bottomPillContainer: View? = null private var bottomBarWrapper: FrameLayout? = null private var suppressCategorySync = false private var currentSearchQuery = "" private var didTriggerExpandForCurrentDrag = false private var initialTouchY = 0f private var lastTopPullDragDistance = 0f private var isHandlingTopPullDrag = false private var isSheetExpanded = false private var isSheetExpansionInProgress = false private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat() private var velocityTracker: VelocityTracker? = null private var topPullStartY: Float? = null private val topPullActivationThresholdPx = 24f * context.resources.displayMetrics.density private val keyboardResultBottomGapPx = (16f * context.resources.displayMetrics.density).toInt() private var baseRecyclerBottomPadding = 0 private var keyboardRecyclerBottomPadding = 0 private var usesDockedSheetKeyboardInsets = false private var viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var loadJob: Job? = null private var searchJob: Job? = null init { orientation = VERTICAL layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) // Search bar searchBar = EmojiSearchBar(context, { query -> onSearch(query) }, { hasFocus -> onSearchFocused?.invoke(hasFocus) if (categoryBarPosition == "bottom") { bottomPillContainer?.visibility = if (hasFocus) View.GONE else View.VISIBLE } }) addView(searchBar) // Category strip (starts with just frequently_used, rebuilt after data loads) allCategoryKeys = listOf("frequently_used") categoryStrip = EmojiCategoryStrip(context, allCategoryKeys) { index -> scrollToCategory(index) } addView(categoryStrip) // Grid gridAdapter = EmojiGridAdapter( theme = currentTheme, onEmojiClick = { emoji, emojiId -> handleEmojiClick(emoji, emojiId) }, onEmojiLongPress = { view, baseEmoji, emojiId -> showSkinTonePicker(view, baseEmoji, emojiId) } ) gridLayoutManager = GridLayoutManager(context, columns) gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return if (gridAdapter.getItemViewType(position) == EmojiGridAdapter.VIEW_TYPE_HEADER) { gridAdapter.spanCount } else { 1 } } } recyclerView = RecyclerView(context).apply { layoutManager = gridLayoutManager adapter = gridAdapter stickyHeaderDecoration = StickyHeaderDecoration(gridAdapter, currentTheme.backgroundColor) addItemDecoration(stickyHeaderDecoration) setHasFixedSize(false) overScrollMode = View.OVER_SCROLL_NEVER isNestedScrollingEnabled = true // Prevent BottomSheet from intercepting scroll when grid can scroll addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { override fun onInterceptTouchEvent(rv: RecyclerView, e: android.view.MotionEvent): Boolean { velocityTracker?.addMovement(e) when (e.actionMasked) { android.view.MotionEvent.ACTION_DOWN -> { velocityTracker?.recycle() velocityTracker = VelocityTracker.obtain().apply { addMovement(e) } initialTouchY = e.y didTriggerExpandForCurrentDrag = false lastTopPullDragDistance = 0f isHandlingTopPullDrag = false topPullStartY = null // While the sheet is expanding, halt any momentum scroll but do // NOT swallow the gesture: intercepting DOWN/UP would cancel an // emoji tap. Only a drag (ACTION_MOVE below) is intercepted, so a // plain tap still reaches the cell even mid-expansion. if (isSheetExpansionInProgress) { rv.stopScroll() } } android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> { didTriggerExpandForCurrentDrag = false velocityTracker?.computeCurrentVelocity(1000) val releaseVelocityY = velocityTracker?.yVelocity ?: 0f if (isHandlingTopPullDrag) { onPullDownAtTopRelease?.invoke(lastTopPullDragDistance, releaseVelocityY) } lastTopPullDragDistance = 0f isHandlingTopPullDrag = false topPullStartY = null velocityTracker?.recycle() velocityTracker = null if (isSheetExpansionInProgress) { rv.stopScroll() } } } if (e.actionMasked == android.view.MotionEvent.ACTION_MOVE) { if (isSheetExpansionInProgress) { // Only steal the gesture once it becomes a real drag (past touch // slop). A finger tap jitters a pixel or two; intercepting that // here cancels the emoji tap whenever this flag is set while the // keyboard is up — which is exactly the "can't tap while searching" // bug. Sub-slop movement falls through so the cell click fires. if (kotlin.math.abs(e.y - initialTouchY) > touchSlop) { rv.stopScroll() return true } return false } val deltaY = e.y - initialTouchY val isAtTop = !rv.canScrollVertically(-1) if ( !isSheetExpanded && onScrollIntentUp != null && deltaY < -touchSlop && !didTriggerExpandForCurrentDrag ) { didTriggerExpandForCurrentDrag = true isSheetExpansionInProgress = true rv.stopScroll() onScrollIntentUp?.invoke() return true } if (isHandlingTopPullDrag) { rv.parent?.requestDisallowInterceptTouchEvent(true) rv.stopScroll() val baselineY = topPullStartY ?: e.y val dragDistance = maxOf(0f, e.y - baselineY) lastTopPullDragDistance = dragDistance onPullDownAtTopDrag?.invoke(dragDistance) } else if (isAtTop && deltaY > topPullActivationThresholdPx) { rv.parent?.requestDisallowInterceptTouchEvent(true) rv.stopScroll() isHandlingTopPullDrag = true topPullStartY = e.y lastTopPullDragDistance = 0f onPullDownAtTopDrag?.invoke(0f) } else if (rv.canScrollVertically(-1) || rv.canScrollVertically(1)) { rv.parent?.requestDisallowInterceptTouchEvent(true) } } else if (rv.canScrollVertically(-1) || rv.canScrollVertically(1)) { rv.parent?.requestDisallowInterceptTouchEvent(true) } return false } }) val lp = LayoutParams( LayoutParams.MATCH_PARENT, 0, 1f ) layoutParams = lp } recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(rv: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { didTriggerExpandForCurrentDrag = false } } override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { if (!isSheetExpanded && onScrollIntentUp != null) { rv.stopScroll() return } if (dy > 0 && rv.scrollState != RecyclerView.SCROLL_STATE_IDLE && !didTriggerExpandForCurrentDrag) { didTriggerExpandForCurrentDrag = true onScrollIntentUp?.invoke() } if (suppressCategorySync || isSearchActive) return val firstVisible = gridLayoutManager.findFirstVisibleItemPosition() if (firstVisible != RecyclerView.NO_POSITION) { val catIndex = gridAdapter.getCategoryIndexForPosition(firstVisible) categoryStrip.setSelectedCategory(catIndex) } } }) // Wrap grid + empty state in a FrameLayout so they share the same space val density = context.resources.displayMetrics.density emptyStateLabel = TextView(context).apply { text = "No emojis found" setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP visibility = View.GONE setPadding(0, (40 * density).toInt(), 0, 0) layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) } contentFrame = FrameLayout(context).apply { val lp = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f) layoutParams = lp addView(recyclerView.also { it.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) }) addView(emptyStateLabel) } addView(contentFrame) ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> setKeyboardBottomInset( imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom, navigationBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom ) insets } applyTheme(currentTheme) applyLayoutDirection() } fun setSheetExpanded(expanded: Boolean) { isSheetExpanded = expanded if (expanded) { isSheetExpansionInProgress = false } } fun setSheetExpansionInProgress(inProgress: Boolean) { isSheetExpansionInProgress = inProgress if (inProgress) { recyclerView.stopScroll() } } fun setKeyboardBottomInset(imeBottom: Int, navigationBottom: Int) { if (usesDockedSheetKeyboardInsets) return val keyboardOnlyBottom = maxOf(0, imeBottom - navigationBottom) setKeyboardRecyclerBottomPadding( if (keyboardOnlyBottom > 0) keyboardOnlyBottom + keyboardResultBottomGapPx else 0 ) } fun setKeyboardDockedToSheet(keyboardVisible: Boolean) { usesDockedSheetKeyboardInsets = true setKeyboardRecyclerBottomPadding( if (keyboardVisible) keyboardResultBottomGapPx else 0 ) } private fun setKeyboardRecyclerBottomPadding(bottomPadding: Int) { if (keyboardRecyclerBottomPadding == bottomPadding) return keyboardRecyclerBottomPadding = bottomPadding updateRecyclerBottomPadding() } /** * Call after setting configurable properties to apply grid + layout changes. * Safe to call repeatedly: grid settings are always refreshed, and the * category-bar hierarchy is only moved when the target position actually changes. */ fun applyConfiguration() { // Update grid adapter settings (idempotent — safe on every call) gridAdapter.spanCount = columns gridAdapter.emojiTextSize = emojiSize gridAdapter.enableSkinTones = enableSkinTones gridAdapter.enableHaptics = enableHaptics gridAdapter.enableAnimations = enableAnimations gridLayoutManager.spanCount = columns // Show/hide search searchBar.visibility = if (showSearch) View.VISIBLE else View.GONE applyCategoryBarPosition() } private fun applyCategoryBarPosition() { if (categoryBarPosition == "bottom") { if (bottomBarWrapper != null) return // already wrapped; do not re-move the hierarchy moveCategoryBarToBottom() } else { if (bottomBarWrapper == null) { // Already in the default top layout. baseRecyclerBottomPadding = 0 updateRecyclerBottomPadding() return } restoreCategoryBarToTop() } } private fun moveCategoryBarToBottom() { // Detach the strip and content frame from their current parents (whatever they are). (categoryStrip.parent as? android.view.ViewGroup)?.removeView(categoryStrip) (contentFrame.parent as? android.view.ViewGroup)?.removeView(contentFrame) val density = context.resources.displayMetrics.density val horizontalInset = (16 * density).toInt() val bottomInset = (8 * density).toInt() val stripHeight = (44 * density).toInt() val cornerRadius = 22 * density val wrapperFrame = FrameLayout(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f) } contentFrame.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) wrapperFrame.addView(contentFrame) // Floating pill container with rounded background val pillBackground = android.graphics.drawable.GradientDrawable().apply { setColor(currentTheme.categoryBarBackgroundColor) this.cornerRadius = cornerRadius } val pillContainer = FrameLayout(context).apply { background = pillBackground elevation = 8 * density clipToOutline = true outlineProvider = android.view.ViewOutlineProvider.BACKGROUND } categoryStrip.setBackgroundColor(android.graphics.Color.TRANSPARENT) categoryStrip.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, stripHeight ) pillContainer.addView(categoryStrip) pillContainer.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, stripHeight ).apply { gravity = Gravity.BOTTOM setMargins(horizontalInset, 0, horizontalInset, bottomInset) } bottomPillContainer = pillContainer wrapperFrame.addView(pillContainer) // Bottom padding so grid content scrolls above the floating bar val totalBarSpace = stripHeight + bottomInset * 2 baseRecyclerBottomPadding = totalBarSpace updateRecyclerBottomPadding() bottomBarWrapper = wrapperFrame addView(wrapperFrame) } private fun restoreCategoryBarToTop() { val wrapper = bottomBarWrapper ?: return // Detach views from the wrapper, then drop the wrapper itself. (categoryStrip.parent as? android.view.ViewGroup)?.removeView(categoryStrip) (contentFrame.parent as? android.view.ViewGroup)?.removeView(contentFrame) removeView(wrapper) bottomBarWrapper = null bottomPillContainer = null // Restore the default top order: searchBar, categoryStrip, contentFrame. categoryStrip.setBackgroundColor(android.graphics.Color.TRANSPARENT) categoryStrip.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) addView(categoryStrip, indexOfChild(searchBar) + 1) contentFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f) addView(contentFrame) categoryStrip.applyTheme(currentTheme) applyLayoutDirection() baseRecyclerBottomPadding = 0 updateRecyclerBottomPadding() } private fun setGridItems(items: List, sectionPositions: List) { gridAdapter.setItems(items, sectionPositions) stickyHeaderDecoration.invalidateCache() recyclerView.invalidateItemDecorations() } private fun updateRecyclerBottomPadding() { recyclerView.setPadding( recyclerView.paddingLeft, recyclerView.paddingTop, recyclerView.paddingRight, baseRecyclerBottomPadding + keyboardRecyclerBottomPadding ) recyclerView.clipToPadding = false } var searchPlaceholder: String? = null set(value) { field = value; if (value != null) searchBar.setHint(value) } var noResultsText: String? = null set(value) { field = value; if (value != null) emptyStateLabel.text = value } fun loadDataAsync() { loadJob?.cancel() val appContext = context.applicationContext loadJob = viewScope.launch { val data = withContext(Dispatchers.Default) { loadCachedData(appContext) } val categories = data.first val keywords = data.second allCategories = categories localizedKeywords = keywords allCategoryKeys = buildCategoryKeys() rebuildCategoryStrip() buildAndSetItems() } } fun rebuildAfterPropChange() { if (allCategories.isEmpty()) return allCategoryKeys = buildCategoryKeys() rebuildCategoryStrip() if (currentSearchQuery.isBlank()) buildAndSetItems() else onSearch(currentSearchQuery) } fun updateTheme(theme: String) { currentTheme = EmojiSheetTheme.fromName(theme) applyTheme(currentTheme) } fun applyCustomTheme(theme: EmojiSheetTheme) { currentTheme = theme applyTheme(currentTheme) } private fun applyTheme(theme: EmojiSheetTheme) { setBackgroundColor(theme.backgroundColor) searchBar.applyTheme(theme) categoryStrip.applyTheme(theme) gridAdapter.updateTheme(theme) recyclerView.setBackgroundColor(theme.backgroundColor) stickyHeaderDecoration.backgroundColor = theme.backgroundColor stickyHeaderDecoration.invalidateCache() emptyStateLabel.setTextColor(theme.textSecondaryColor) } private fun applyLayoutDirection() { val dir = when (layoutDirectionProp) { "rtl" -> View.LAYOUT_DIRECTION_RTL "ltr" -> View.LAYOUT_DIRECTION_LTR else -> View.LAYOUT_DIRECTION_LOCALE } layoutDirection = dir searchBar.layoutDirection = dir categoryStrip.applyLayoutDirection(dir) recyclerView.layoutDirection = dir contentFrame.layoutDirection = dir stickyHeaderDecoration.invalidateCache() recyclerView.invalidateItemDecorations() requestLayout() } private fun buildCategoryKeys(): List { val keys = mutableListOf() if (showRecents && getFrequentlyUsed().isNotEmpty()) { keys.add("frequently_used") } for (cat in allCategories) { keys.add(cat.title) } return keys } private fun buildAndSetItems() { val items = mutableListOf() val sectionPositions = mutableListOf() // Frequently used (only if there are actual entries) if (showRecents) { val freq = getFrequentlyUsed().filter { it.id !in excludeEmojis } if (freq.isNotEmpty()) { sectionPositions.add(items.size) items.add(EmojiGridAdapter.ListItem.Header( EmojiData.displayName("frequently_used", categoryNames), "frequently_used" )) } for (entry in freq) { val resolvedEmoji = resolveSkinTone(entry.emoji, entry.id, entry.toneEnabled) items.add(EmojiGridAdapter.ListItem.Emoji( emoji = resolvedEmoji, name = entry.name, toneEnabled = entry.toneEnabled, keywords = entry.keywords, id = entry.id )) } } // Regular categories for (cat in allCategories) { val filtered = cat.data.filter { it.id !in excludeEmojis } if (filtered.isEmpty()) continue sectionPositions.add(items.size) items.add(EmojiGridAdapter.ListItem.Header( EmojiData.displayName(cat.title, categoryNames), cat.title )) for (emoji in filtered) { val resolvedEmoji = resolveSkinTone(emoji.emoji, emoji.id, emoji.toneEnabled) items.add(EmojiGridAdapter.ListItem.Emoji( emoji = resolvedEmoji, name = emoji.name, toneEnabled = emoji.toneEnabled, keywords = emoji.keywords, id = emoji.id )) } } setGridItems(items, sectionPositions) // Rebuild category keys in case frequently_used changed val newKeys = buildCategoryKeys() if (newKeys != allCategoryKeys) { allCategoryKeys = newKeys rebuildCategoryStrip() } } private fun rebuildCategoryStrip() { val parent = categoryStrip.parent if (parent is android.view.ViewGroup) { val index = parent.indexOfChild(categoryStrip) parent.removeView(categoryStrip) categoryStrip = EmojiCategoryStrip(context, allCategoryKeys) { catIndex -> scrollToCategory(catIndex) } parent.addView(categoryStrip, index) } else { val index = indexOfChild(categoryStrip) removeView(categoryStrip) categoryStrip = EmojiCategoryStrip(context, allCategoryKeys) { catIndex -> scrollToCategory(catIndex) } addView(categoryStrip, index) } categoryStrip.applyTheme(currentTheme) applyLayoutDirection() } private fun resolveSkinTone(baseEmoji: String, emojiId: String, toneEnabled: Boolean): String { if (!toneEnabled) return baseEmoji val savedTone = EmojiSkinTonePicker.getSavedTone(context, emojiId) return if (savedTone != null) { EmojiData.applyTone(baseEmoji, savedTone) } else { baseEmoji } } private fun scrollToCategory(index: Int) { val positions = gridAdapter.getSectionPositions() if (index in positions.indices) { suppressCategorySync = true gridLayoutManager.scrollToPositionWithOffset(positions[index], 0) recyclerView.post { suppressCategorySync = false } } } // Search runs on a cancellable coroutine job. The generation counter remains // as a small secondary guard so stale results cannot apply after a newer query. private var searchGeneration = 0 private fun onSearch(query: String) { val trimmedQuery = query.trim() currentSearchQuery = trimmedQuery searchJob?.cancel() if (trimmedQuery.isEmpty()) { searchGeneration += 1 isSearchActive = false categoryStrip.visibility = View.VISIBLE bottomPillContainer?.visibility = View.VISIBLE emptyStateLabel.visibility = View.GONE recyclerView.visibility = View.VISIBLE buildAndSetItems() recyclerView.postDelayed({ recyclerView.scrollToPosition(0) }, 100) return } isSearchActive = true categoryStrip.visibility = View.GONE bottomPillContainer?.visibility = View.GONE emptyStateLabel.visibility = View.GONE recyclerView.visibility = View.VISIBLE setGridItems(emptyList(), emptyList()) recyclerView.scrollToPosition(0) val generation = ++searchGeneration val categories = allCategories val keywords = localizedKeywords val exclude = excludeEmojis searchJob = viewScope.launch { val matchedItems = withContext(Dispatchers.Default) { val normalizedQueryVariants = EmojiData.normalizedSearchVariants(trimmedQuery) val scored = mutableListOf>() for (cat in categories) { currentCoroutineContext().ensureActive() for (emoji in cat.data) { currentCoroutineContext().ensureActive() if (emoji.id in exclude) continue val score = relevanceScore(emoji, normalizedQueryVariants, keywords) if (score > 0) { val resolved = resolveSkinTone(emoji.emoji, emoji.id, emoji.toneEnabled) scored.add(Pair(EmojiGridAdapter.ListItem.Emoji( emoji = resolved, name = emoji.name, toneEnabled = emoji.toneEnabled, keywords = emoji.keywords, id = emoji.id ), score)) } } } scored.sortByDescending { it.second } scored.map { it.first } } if (generation != searchGeneration) return@launch val results = mutableListOf() val sectionPositions = mutableListOf() if (matchedItems.isNotEmpty()) { sectionPositions.add(0) results.add(EmojiGridAdapter.ListItem.Header("Search Results", "search")) results.addAll(matchedItems) } emptyStateLabel.visibility = if (matchedItems.isNotEmpty()) View.GONE else View.VISIBLE recyclerView.visibility = if (matchedItems.isNotEmpty()) View.VISIBLE else View.GONE setGridItems(results, sectionPositions) recyclerView.scrollToPosition(0) } } private fun handleEmojiClick(emoji: String, emojiId: String) { trackFrequentlyUsed(emojiId) refreshVisibleItemsAfterUsage() val name = findEmojiName(emojiId) ?: "" val data = mapOf("emoji" to emoji, "name" to name, "id" to emojiId) onEmojiSelected?.invoke(data) } private fun findEmojiName(emojiId: String): String? { for (cat in allCategories) { for (emoji in cat.data) { if (emoji.id == emojiId) return emoji.name } } return null } private fun showSkinTonePicker(anchorView: View, baseEmoji: String, emojiId: String) { if (!enableSkinTones) return if (enableHaptics) { anchorView.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) } val originalBase = findBaseEmoji(emojiId) ?: baseEmoji val picker = EmojiSkinTonePicker(context, currentTheme, enableHaptics) { selectedEmoji -> trackFrequentlyUsed(emojiId) refreshVisibleItemsAfterUsage() val name = findEmojiName(emojiId) ?: "" val data = mapOf("emoji" to selectedEmoji, "name" to name, "id" to emojiId) onEmojiSelected?.invoke(data) } picker.show(anchorView, originalBase, emojiId) } private fun findBaseEmoji(emojiId: String): String? { for (cat in allCategories) { for (emoji in cat.data) { if (emoji.id == emojiId) return emoji.emoji } } return null } // --- Frequently Used --- private fun getFreqPrefs(): SharedPreferences = context.getSharedPreferences(FREQ_PREFS, Context.MODE_PRIVATE) private fun trackFrequentlyUsed(emojiId: String) { val prefs = getFreqPrefs() val currentCount = prefs.getInt(emojiId + FREQ_COUNT_SUFFIX, 0) prefs.edit() .putInt(emojiId + FREQ_COUNT_SUFFIX, currentCount + 1) .putLong(emojiId + FREQ_DAY_SUFFIX, getStartOfDayMillis()) .putLong(emojiId + FREQ_TIME_SUFFIX, System.currentTimeMillis()) .apply() } private fun getFrequentlyUsed(): List { val prefs = getFreqPrefs() val all = prefs.all val entries = mutableListOf>() val seen = mutableSetOf() for ((key, value) in all) { if (key.endsWith(FREQ_COUNT_SUFFIX)) { val emojiId = key.removeSuffix(FREQ_COUNT_SUFFIX) if (seen.add(emojiId)) { val count = value as? Int ?: 0 val lastUsed = prefs.getLong(emojiId + FREQ_TIME_SUFFIX, 0) entries.add(Triple(emojiId, count, lastUsed)) } } } entries.sortWith( compareByDescending> { prefs.getLong(it.first + FREQ_DAY_SUFFIX, 0) } .thenByDescending { it.third } .thenBy { it.first } ) val top = entries.take(recentLimit) val emojiMap = mutableMapOf() for (cat in allCategories) { for (emoji in cat.data) { emojiMap[emoji.id] = emoji } } return top.mapNotNull { (id, _, _) -> emojiMap[id] } } private fun refreshVisibleItemsAfterUsage() { if (currentSearchQuery.isBlank()) { buildAndSetItems() } else { onSearch(currentSearchQuery) } } private fun stripVariationSelectors(emoji: String): String { return emoji.filter { it.code != 0xFE0E && it.code != 0xFE0F } } // Relevance scoring for search results: // 100 = exact name match, 90 = name starts with, 80 = exact keyword, // 70 = keyword starts with, 50 = name contains, 30 = keyword contains, // 10 = localized keyword contains. Returns 0 for no match. private fun relevanceScore( emoji: EmojiItem, queryVariants: Set, localizedKeywords: Map> ): Int { // Check name for (variant in queryVariants) { if (emoji.normalizedName == variant) return 100 if (emoji.normalizedName.startsWith(variant)) return 90 } // Check built-in keywords var bestScore = 0 for (kwNorm in emoji.normalizedKeywords) { for (variant in queryVariants) { if (kwNorm == variant) bestScore = maxOf(bestScore, 80) else if (kwNorm.startsWith(variant)) bestScore = maxOf(bestScore, 70) else if (kwNorm.contains(variant)) bestScore = maxOf(bestScore, 30) } if (bestScore >= 80) break } // Check name contains (lower priority than keyword exact/startsWith) if (bestScore < 50) { for (variant in queryVariants) { if (emoji.normalizedName.contains(variant)) bestScore = maxOf(bestScore, 50) } } if (bestScore > 0) return bestScore // Check localized keywords val localKw = localizedKeywords[emoji.emoji] ?: localizedKeywords[stripVariationSelectors(emoji.emoji)] if (localKw != null) { for (kw in localKw) { if (queryVariants.any { kw.contains(it) }) return 10 } } return 0 } private fun getStartOfDayMillis(): Long { val calendar = java.util.Calendar.getInstance() calendar.set(java.util.Calendar.HOUR_OF_DAY, 0) calendar.set(java.util.Calendar.MINUTE, 0) calendar.set(java.util.Calendar.SECOND, 0) calendar.set(java.util.Calendar.MILLISECOND, 0) return calendar.timeInMillis } override fun onDetachedFromWindow() { super.onDetachedFromWindow() loadJob?.cancel() searchJob?.cancel() viewScope.cancel() } override fun onAttachedToWindow() { super.onAttachedToWindow() if (!viewScope.isActive) { viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) // Only reload when data hasn't loaded yet; otherwise the populated grid // and category strip survive the detach, so re-running loadDataAsync would // rebuild the strip and reset scroll to 0 on every reattach. if (allCategories.isEmpty()) loadDataAsync() } ViewCompat.requestApplyInsets(this) } }