Introduce and integrate new media keyboard.

This commit is contained in:
Alex Hart
2026-09-23 16:12:21 -04:00
committed by Michelle Tang
parent 442d5ec5af
commit 417bd70296
116 changed files with 5973 additions and 751 deletions
+1
View File
@@ -711,6 +711,7 @@ dependencies {
implementation(project(":feature:app-settings"))
implementation(project(":feature:camera"))
implementation(project(":feature:chat-settings"))
implementation(project(":feature:media-keyboard"))
implementation(project(":feature:registration"))
implementation(libs.androidx.fragment.ktx)
@@ -1,77 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose.mediakeyboard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Asks a [MediaKeyboardScaffold] for a keyboard.
*
* Stable and safe to hold outside composition, so view code can call into it. Everything flowing the
* other way is a [MediaKeyboardEvents].
*
* @param initialKeyboardHeightPx Height to use before one has been measured, typically persisted
* from an earlier run.
*/
@Stable
class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) {
/** The keyboard currently up, or null when none of ours is. */
var current: MediaKeyboardKey? by mutableStateOf(null)
private set
/** Whether the system keyboard is up, from the target of the IME inset animation. */
var isSystemKeyboardVisible: Boolean by mutableStateOf(false)
internal set
/**
* True while the system keyboard has been asked for in place of one of ours but has yet to settle.
* Holds the space across that gap, which the IME service round trip would otherwise leave empty.
*/
var awaitingSystemKeyboard: Boolean by mutableStateOf(false)
internal set
/** How tall a keyboard of ours should be, replaced whenever a system keyboard is measured. */
var keyboardHeightPx: Int by mutableStateOf(initialKeyboardHeightPx)
internal set
val isShowing: Boolean get() = current != null
fun show(key: MediaKeyboardKey) {
current = key
awaitingSystemKeyboard = false
}
fun hide() {
current = null
awaitingSystemKeyboard = false
}
/** Closes [key] if it is already up, otherwise swaps to it. */
fun toggle(key: MediaKeyboardKey) {
if (current == key) hide() else show(key)
}
/**
* Puts ours away because the system keyboard is being brought up instead. Only holds the space if
* one of ours was up; with nothing to hand over, content should just follow the keyboard in.
*/
fun hideForSystemKeyboard() {
awaitingSystemKeyboard = current != null
current = null
}
}
/** @param initialKeyboardHeightPx See [MediaKeyboardController]. */
@Composable
fun rememberMediaKeyboardController(initialKeyboardHeightPx: Int = 0): MediaKeyboardController {
return remember { MediaKeyboardController(initialKeyboardHeightPx) }
}
@@ -1,15 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose.mediakeyboard
/**
* Identifies a keyboard a [MediaKeyboardScaffold] can put up. Callers declare their own, so a screen
* only knows about the keyboards it offers.
*
* @param name Unique identifier for the keyboard.
*/
@JvmInline
value class MediaKeyboardKey(val name: String)
@@ -1,346 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose.mediakeyboard
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.navigationBarsCompat
import org.signal.core.ui.compose.safeDrawingCompat
import org.signal.core.ui.compose.systemBarsCompat
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isHeightCompact
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
/** Shapes back gesture progress into travel. Matches the platform's IME hide curve. */
private val BACK_TRACKING_EASING = FastOutLinearInEasing
/** Settles the keyboard once a back gesture lets go. Lower stiffness is heavier. */
private val BACK_SETTLE_MOTION = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
)
/** How long to hold space for a system keyboard that was asked for but never appeared. */
private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds
private val SHEET_POSITIONAL_THRESHOLD = 56.dp
private val SHEET_VELOCITY_THRESHOLD = 125.dp
/**
* Displays [content] alongside keyboards of our own that stand in for the system keyboard.
*
* Only one is ever up, and never alongside the system keyboard. The scaffold owns every window inset,
* handing [content] an already-inset space to lay out in.
*
* @param controller Requests which keyboard is up. Stable, so view code may hold one.
* @param onEvent Receives everything the host may need to react to.
* @param keyboardsProvider Declares the available keyboards.
* @param keyboardHeight Bounds on how tall a keyboard may be.
* @param adjustContentForInput False to let a keyboard cover [content] rather than resize it.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun MediaKeyboardScaffold(
controller: MediaKeyboardController,
onEvent: (MediaKeyboardEvents) -> Unit,
keyboardsProvider: MediaKeyboardScope.() -> Unit,
modifier: Modifier = Modifier,
keyboardHeight: MediaKeyboardHeight = MediaKeyboardHeight(),
adjustContentForInput: Boolean = true,
content: @Composable () -> Unit
) {
val registry = remember(keyboardsProvider) { MediaKeyboardRegistry().apply(keyboardsProvider) }
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val imeAnimationTarget = WindowInsets.imeAnimationTarget
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val resources = LocalResources.current
val configuration = LocalConfiguration.current
val isHeightCompact = remember(resources, configuration) { resources.getWindowSizeClass().isHeightCompact }
val minimumHeightPx = with(density) { keyboardHeight.minimum.roundToPx() }
val topMarginPx = with(density) { keyboardHeight.topMargin.roundToPx() }
val activeKey = controller.current?.takeIf { registry.isEnabled(it) }
// The target, so a hide reads as gone the moment it is asked for rather than a whole animation later.
val systemKeyboardVisible = imeAnimationTarget.getBottom(density) > 0
// Whether the live inset has yet to catch up with the target. Deliberately not imeAnimationSource,
// which the platform leaves behind whenever an animation ends without a duration to run down, and
// which would then read as animating until the next keyboard came and went. Derived, so the frames
// the live inset walks through are not each a recomposition.
val systemKeyboardAnimating by remember(imeInsets, imeAnimationTarget, density) {
derivedStateOf { imeInsets.getBottom(density) != imeAnimationTarget.getBottom(density) }
}
SideEffect {
controller.isSystemKeyboardVisible = systemKeyboardVisible
}
// The controller outlives us. current is left set so a rebuilt view restores its keyboard.
DisposableEffect(controller) {
onDispose {
controller.isSystemKeyboardVisible = false
controller.awaitingSystemKeyboard = false
}
}
var hasReportedKeyboardVisibility by remember { mutableStateOf(false) }
LaunchedEffect(systemKeyboardVisible) {
if (hasReportedKeyboardVisibility) {
onEvent(MediaKeyboardEvents.SystemKeyboardVisibilityChanged(systemKeyboardVisible))
}
hasReportedKeyboardVisibility = true
}
LaunchedEffect(controller.awaitingSystemKeyboard) {
if (controller.awaitingSystemKeyboard) {
delay(SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT)
controller.awaitingSystemKeyboard = false
}
}
// From the animation target, not the live inset, which walks down through every closing frame.
LaunchedEffect(imeAnimationTarget, density, minimumHeightPx, isHeightCompact) {
if (isHeightCompact) {
return@LaunchedEffect
}
snapshotFlow { imeAnimationTarget.getBottom(density) }
.filter { it > minimumHeightPx }
.distinctUntilChanged()
.collect {
controller.keyboardHeightPx = it
onEvent(MediaKeyboardEvents.SystemKeyboardHeightMeasured(it))
}
}
// dropWhile rather than drop, so a composition that starts mid-animation still reports the settle
// it is in the middle of instead of swallowing it as the initial state.
LaunchedEffect(imeInsets, imeAnimationTarget, density) {
snapshotFlow { imeInsets.getBottom(density) == imeAnimationTarget.getBottom(density) }
.distinctUntilChanged()
.dropWhile { settled -> settled }
.filter { settled -> settled }
.collect {
controller.awaitingSystemKeyboard = false
onEvent(MediaKeyboardEvents.SystemKeyboardAnimationEnded)
}
}
val heightPx = keyboardHeight.resolve(
preferredPx = controller.keyboardHeightPx,
windowHeightPx = windowHeightPx,
minimumPx = minimumHeightPx,
topMarginPx = topMarginPx
)
val height = with(density) { heightPx.toDp() }
val backProgress = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val sheetState = remember {
SheetState(
skipPartiallyExpanded = true,
positionalThreshold = { with(density) { SHEET_POSITIONAL_THRESHOLD.toPx() } },
velocityThreshold = { with(density) { SHEET_VELOCITY_THRESHOLD.toPx() } },
initialValue = SheetValue.Hidden,
skipHiddenState = false
)
}
val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState)
// This just makes sure the previously visible state doesn't go away too early while we're mid swap.
var visibleKey by remember { mutableStateOf<MediaKeyboardKey?>(null) }
LaunchedEffect(activeKey) {
if (activeKey != null) {
if (visibleKey != null && visibleKey != activeKey) {
onEvent(MediaKeyboardEvents.KeyboardHidden)
}
visibleKey = activeKey
backProgress.snapTo(0f)
sheetState.expand()
onEvent(MediaKeyboardEvents.KeyboardShown(activeKey))
} else if (visibleKey != null) {
// A gesture may already have carried it off screen; hold that until the hide completes.
sheetState.hide()
backProgress.snapTo(0f)
visibleKey = null
onEvent(MediaKeyboardEvents.KeyboardHidden)
}
}
PredictiveBackHandler(enabled = activeKey != null) { progress ->
try {
progress.collect { backEvent -> backProgress.snapTo(BACK_TRACKING_EASING.transform(backEvent.progress)) }
backProgress.animateTo(1f, BACK_SETTLE_MOTION)
controller.hide()
onEvent(MediaKeyboardEvents.DismissedByBack)
} catch (cancelled: CancellationException) {
// PredictiveBackHandler cancels this job, so the unwind must run somewhere that outlives it.
scope.launch { backProgress.animateTo(0f, BACK_SETTLE_MOTION) }
throw cancelled
}
}
val systemKeyboardTakingOverSpace = activeKey == null &&
(controller.awaitingSystemKeyboard || (systemKeyboardVisible && systemKeyboardAnimating))
val claimedBottomPx = {
if (activeKey != null) {
(heightPx * (1f - backProgress.value)).roundToInt().coerceAtLeast(0)
} else if (systemKeyboardTakingOverSpace) {
heightPx
} else {
0
}
}
var ancestorConsumedBottomPx by remember { mutableIntStateOf(0) }
val safeDrawingInsets = WindowInsets.safeDrawingCompat
val windowInsets = if (adjustContentForInput) {
safeDrawingInsets
} else {
WindowInsets.systemBarsCompat.add(WindowInsets.displayCutout)
}
Box(modifier = modifier.fillMaxSize()) {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = 0.dp,
sheetShape = RectangleShape,
sheetDragHandle = null,
sheetSwipeEnabled = false,
sheetContainerColor = Color.Transparent,
sheetTonalElevation = 0.dp,
sheetShadowElevation = 0.dp,
containerColor = Color.Transparent,
sheetContent = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.graphicsLayer { translationY = backProgress.value * heightPx }
.background(registry.containerColorFor(visibleKey).takeOrElse { MaterialTheme.colorScheme.surfaceContainerLow })
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
) {
registry.contentFor(visibleKey)?.invoke()
}
}
) { _ ->
Box(
modifier = Modifier
.fillMaxSize()
.onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) }
.windowInsetsPadding(windowInsets)
.layout { measurable, constraints ->
// Window insets are already out of these constraints; take only the excess claim.
val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0)
val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0
val available = (constraints.maxHeight - extraPx).coerceAtLeast(0)
val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available))
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(0, 0)
}
}
) {
content()
}
}
}
}
/**
* Bounds on how tall a keyboard may be. The height it wants comes from
* [MediaKeyboardController.keyboardHeightPx].
*
* Holds a lambda, so callers should [remember] it or the scaffold cannot skip recomposition.
*
* @param minimum Floor, used before any keyboard has been measured.
* @param topMargin Kept clear of the top of the window, capping the height.
* @param overrideForWindow Derives a height from the window instead, for windows unlike the one a
* keyboard was measured in.
*/
@Immutable
data class MediaKeyboardHeight(
val minimum: Dp = 260.dp,
val topMargin: Dp = 170.dp,
val overrideForWindow: ((windowHeightPx: Int) -> Int)? = null
) {
internal fun resolve(preferredPx: Int, windowHeightPx: Int, minimumPx: Int, topMarginPx: Int): Int {
if (windowHeightPx <= 0) {
return maxOf(preferredPx, minimumPx)
}
val maximumPx = (windowHeightPx - topMarginPx).coerceAtLeast(minimumPx)
overrideForWindow?.let { return it(windowHeightPx).coerceAtMost(maximumPx) }
return preferredPx.coerceIn(minimumPx, maximumPx)
}
}
@@ -1,53 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose.mediakeyboard
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** Declares which keyboards a [MediaKeyboardScaffold] offers. */
interface MediaKeyboardScope {
/**
* Offers the keyboard identified by [key].
*
* @param key Identifies the keyboard.
* @param enabled False to keep the key known but refuse requests for it.
* @param containerColor Fills the sheet, navigation bar included, so it should match whatever
* [content] paints its edges with. Unspecified falls back to the scaffold's surface.
* @param content The keyboard itself.
*/
fun keyboard(
key: MediaKeyboardKey,
enabled: Boolean = true,
containerColor: Color = Color.Unspecified,
content: @Composable () -> Unit
)
}
internal class MediaKeyboardRegistry : MediaKeyboardScope {
private val entries = LinkedHashMap<MediaKeyboardKey, Entry>()
override fun keyboard(
key: MediaKeyboardKey,
enabled: Boolean,
containerColor: Color,
content: @Composable () -> Unit
) {
entries[key] = Entry(enabled, containerColor, content)
}
fun isEnabled(key: MediaKeyboardKey?): Boolean = key != null && entries[key]?.enabled == true
fun contentFor(key: MediaKeyboardKey?): (@Composable () -> Unit)? {
return key?.let { entries[it] }?.takeIf { it.enabled }?.content
}
fun containerColorFor(key: MediaKeyboardKey?): Color {
return key?.let { entries[it] }?.takeIf { it.enabled }?.containerColor ?: Color.Unspecified
}
private class Entry(val enabled: Boolean, val containerColor: Color, val content: @Composable () -> Unit)
}
@@ -8,12 +8,12 @@ package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.view.View
import android.widget.EditText
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Adapts [MediaKeyboardController] to the conversation's view code, which asks for keyboards from
* Adapts [KeyboardSheetController] to the conversation's view code, which asks for keyboards from
* click listeners rather than from composition.
*
* @param context Used to hide the system keyboard.
@@ -21,14 +21,11 @@ import org.thoughtcrime.securesms.util.ViewUtil
*/
class ChatInputController(
private val context: Context,
private val controller: MediaKeyboardController
private val controller: KeyboardSheetController
) {
private var wasKeyboardVisibleBeforeToggle: Boolean = false
private val listeners: MutableSet<Listener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
/** What [runAfterAllHidden] is waiting on. */
private var pendingHiddenAction: (() -> Unit)? = null
@@ -38,49 +35,34 @@ class ChatInputController(
val isKeyboardShowing: Boolean
get() = controller.isSystemKeyboardVisible
fun addInputListener(listener: Listener) {
listeners.add(listener)
/** See [KeyboardSheetController.onHostWindowShown]. */
fun onHostWindowShown() {
controller.onHostWindowShown()
}
fun removeInputListener(listener: Listener) {
listeners.remove(listener)
/** See [KeyboardSheetController.onHostWindowHidden]. */
fun onHostWindowHidden() {
controller.onHostWindowHidden()
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners.add(listener)
}
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners.remove(listener)
}
/** Drops everything still listening, for a host whose view is going away. */
fun clearListeners() {
listeners.clear()
keyboardStateListeners.clear()
/** Drops anything still waiting on a hide, for a host whose view is going away. */
fun clearPendingActions() {
pendingHiddenAction = null
controller.onHostWindowHidden()
}
/**
* Told rather than observed: the host handles the scaffold's own stream, and only passes on the
* two things [runAfterAllHidden] has to wait for.
*/
fun onKeyboardVisibilityChanged(visible: Boolean) {
keyboardStateListeners.toList().forEach {
if (visible) it.onKeyboardShown() else it.onKeyboardHidden()
}
if (!visible) {
runPendingHiddenAction()
}
}
fun onKeyboardAnimationEnded() {
keyboardStateListeners.toList().forEach { it.onKeyboardAnimationEnded() }
}
fun onInputShown(key: MediaKeyboardKey) {
listeners.toList().forEach { it.onInputShown(key) }
}
/** See [onKeyboardVisibilityChanged]. */
fun onInputHidden() {
listeners.toList().forEach { it.onInputHidden() }
runPendingHiddenAction()
}
@@ -135,7 +117,7 @@ class ChatInputController(
* @param imeTarget The field the system keyboard belongs to.
* @param showSoftKeyOnHide Whether the system keyboard replaces [key] when it is taken away.
*/
fun toggleInput(key: MediaKeyboardKey, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
fun toggleInput(key: KeyboardSheetKey, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
// Ours can sit behind the system keyboard rather than in place of it, where hiding it would act
// on something the user cannot see. The system keyboard goes instead.
if (controller.current == key && !isKeyboardShowing) {
@@ -150,15 +132,4 @@ class ChatInputController(
ViewUtil.hideKeyboard(context, imeTarget)
}
}
interface Listener {
fun onInputShown(key: MediaKeyboardKey)
fun onInputHidden()
}
interface KeyboardStateListener {
fun onKeyboardShown()
fun onKeyboardHidden()
fun onKeyboardAnimationEnded() = Unit
}
}
@@ -5,10 +5,10 @@
package org.thoughtcrime.securesms.conversation.v2
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
/** The keyboards the conversation offers in place of the system keyboard. */
object ChatKeyboards {
val Media = MediaKeyboardKey("conversation.media")
val Attachment = MediaKeyboardKey("conversation.attachment")
val Media = KeyboardSheetKey("conversation.media")
val Attachment = KeyboardSheetKey("conversation.attachment")
}
@@ -26,20 +26,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.AndroidFragment
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
import org.signal.core.ui.compose.keyboard.KeyboardSheetHeight
import org.signal.core.ui.compose.keyboard.KeyboardSheetScaffold
import org.signal.core.ui.compose.navigationBarsCompat
import org.signal.core.ui.compose.safeDrawingCompat
import org.signal.core.ui.compose.statusBarsCompat
import org.signal.core.ui.util.ThemeUtil
import org.signal.mediakeyboard.MediaKeyboard
import org.signal.mediakeyboard.MediaKeyboardAction
import org.signal.mediakeyboard.MediaKeyboardTab
import org.signal.mediakeyboard.data.MediaKeyboardRepository
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardHeight
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardScaffold
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment
import kotlin.math.roundToInt
/** A bubble's keyboard takes a little over half the window, as it does for the older hosts. */
@@ -48,8 +52,17 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f
/**
* Displays a chat screen for a given conversation.
*
* @param controller The MediaKeyboardController to control the media keyboard
* @param onEvent The MediaKeyboard events stream for interacting with the media keyboard
* @param controller The KeyboardSheetController to control the media keyboard
* @param onScaffoldAction Receives what the scaffold itself reports: which keyboard is up, and
* what the system keyboard is doing
* @param mediaKeyboardRepository Supplies the media keyboard's emoji, sticker, and gif data
* @param onMediaKeyboardAction Receives what the user picks from the media keyboard, and anything
* only the host can carry out
* @param mediaKeyboardTabs The only media keyboard tabs to offer, or null for all of them
* @param mediaKeyboardInitialTab The media keyboard tab to open on, from the mode remembered across
* runs, or null to open on the first one offered
* @param minimumVisibleContentPx How much of [contentView] the expanded media keyboard must leave
* in view: message rows, plus the toolbar and input panel that sit either side of them
* @param scrim The color information for the top and bottom scrim
* @param isBubble Whether we're displaying content in a bubble
* @param conversationView The conversation's own view hierarchy, and the only interop view here.
@@ -58,8 +71,13 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f
*/
@Composable
fun ChatScreen(
controller: MediaKeyboardController,
onEvent: (MediaKeyboardEvents) -> Unit,
controller: KeyboardSheetController,
onScaffoldAction: (KeyboardSheetAction) -> Unit,
mediaKeyboardRepository: MediaKeyboardRepository,
onMediaKeyboardAction: (MediaKeyboardAction) -> Unit,
mediaKeyboardTabs: Set<MediaKeyboardTab>?,
mediaKeyboardInitialTab: MediaKeyboardTab?,
minimumVisibleContentPx: Int,
scrims: ChatScrimState,
isBubble: Boolean,
conversationView: View,
@@ -71,10 +89,13 @@ fun ChatScreen(
val mediaKeyboardColor = Color(ThemeUtil.getThemedColor(LocalContext.current, R.attr.mediaKeyboardBottomBarBackgroundColor))
val attachmentKeyboardColor = scrims.attachmentKeyboardColor
val keyboardHeight = remember(minimumHeight, topMargin, isBubble) {
MediaKeyboardHeight(
val minimumVisibleContent = with(LocalDensity.current) { minimumVisibleContentPx.toDp() }
val keyboardHeight = remember(minimumHeight, topMargin, minimumVisibleContent, isBubble) {
KeyboardSheetHeight(
minimum = minimumHeight,
topMargin = topMargin,
minimumContentVisible = minimumVisibleContent,
overrideForWindow = if (isBubble) {
{ windowHeightPx -> (windowHeightPx * BUBBLE_HEIGHT_FRACTION).roundToInt() }
} else {
@@ -106,17 +127,20 @@ fun ChatScreen(
.background(Color(scrims.navigationBarColor))
)
MediaKeyboardScaffold(
KeyboardSheetScaffold(
controller = controller,
onEvent = onEvent,
onAction = onScaffoldAction,
keyboardsProvider = {
keyboard(
key = ChatKeyboards.Media,
containerColor = mediaKeyboardColor
containerColor = mediaKeyboardColor,
expandable = true
) {
AndroidFragment(
clazz = KeyboardPagerFragment::class.java,
modifier = Modifier.fillMaxSize()
MediaKeyboard(
repository = mediaKeyboardRepository,
onAction = onMediaKeyboardAction,
tabs = mediaKeyboardTabs,
initialTab = mediaKeyboardInitialTab
)
}
@@ -55,8 +55,13 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.SearchView
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ComposeView
@@ -75,7 +80,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
@@ -121,6 +125,8 @@ import org.signal.core.models.database.StickerRecord
import org.signal.core.models.media.Media
import org.signal.core.models.media.TransformProperties
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
@@ -146,6 +152,8 @@ import org.signal.core.util.requireParcelableCompat
import org.signal.core.util.setActionItemTint
import org.signal.donations.InAppPaymentType
import org.signal.emoji.EmojiEventListener
import org.signal.mediakeyboard.MediaKeyboardAction
import org.signal.mediakeyboard.MediaKeyboardTab
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.MainActivity
@@ -173,10 +181,6 @@ import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
@@ -259,6 +263,7 @@ import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
import org.thoughtcrime.securesms.database.DraftTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
@@ -278,6 +283,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelActivity
@@ -295,12 +302,7 @@ import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.jobs.AttachmentBackfill
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.sticker.StickerSearchDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
@@ -312,6 +314,7 @@ import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.mediakeyboard.SignalMediaKeyboardRepository
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaPreviewActivity
@@ -397,6 +400,7 @@ import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import org.thoughtcrime.securesms.util.toMillis
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@@ -419,13 +423,9 @@ class ConversationFragment :
LoggingFragment(),
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
ReactionsBottomSheetDialogFragment.Callback,
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
GifKeyboardPageFragment.Host,
StickerEventListener,
StickerKeyboardPageFragment.Callback,
MediaKeyboard.MediaKeyboardListener,
EmojiSearchFragment.Callback,
StickerSearchDialogFragment.Callback,
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
ScheduleMessageDialogCallback,
ConversationBottomSheetCallback,
@@ -442,9 +442,11 @@ class ConversationFragment :
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
private const val EMOJI_SEARCH_FRAGMENT_TAG = "EmojiSearchFragment"
private const val MESSAGE_DETAILS_TAG = "MessageDetailsFragment"
/** Message rows the expanded media keyboard has to leave in view. */
private const val MINIMUM_VISIBLE_MESSAGES_DP: Int = 96
private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L
private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
@@ -540,8 +542,6 @@ class ConversationFragment :
ConversationSearchViewModel(getString(R.string.note_to_self))
}
private val keyboardPagerViewModel: KeyboardPagerViewModel by activityViewModels()
private val stickerViewModel: StickerSuggestionsViewModel by viewModel {
StickerSuggestionsViewModel()
}
@@ -603,14 +603,13 @@ class ConversationFragment :
viewModel.setIsSearchRequested(value)
}
/** The keyboard the toggle showed before edit mode forced it to emoji. */
private var previousPage: KeyboardPage? = null
private var previousPages: Set<KeyboardPage>? = null
private var reShowScheduleMessagesBar: Boolean = false
private var composeTextEventsListener: ComposeTextEventsListener? = null
private var dataObserver: DataObserver? = null
private var menuProvider: ConversationOptionsMenu.Provider? = null
private var scrollListener: ScrollListener? = null
private var keyboardEvents: KeyboardEvents? = null
private var progressDialog: ProgressCardDialogFragment? = null
private var firstPinRender: Boolean = true
private var skipNextBackPressHandling: Boolean = false
@@ -638,8 +637,8 @@ class ConversationFragment :
private val chatScreenViewModel: ChatScreenViewModel by viewModels()
/** Stable across recomposition, so view code can ask for a keyboard from a click listener. */
private val mediaKeyboardController: MediaKeyboardController by lazy(LazyThreadSafetyMode.NONE) {
MediaKeyboardController(
private val mediaKeyboardController: KeyboardSheetController by lazy(LazyThreadSafetyMode.NONE) {
KeyboardSheetController(
initialKeyboardHeightPx = chatScreenViewModel.getStoredKeyboardHeight(
isLandscape = isLandscape(),
minimumPx = resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
@@ -650,6 +649,101 @@ class ConversationFragment :
/** Colours for the scrims ChatScreen paints. */
private val chatScrims = ChatScrimState()
/**
* Narrows the media keyboard while editing a message, where an edit can only carry text, so
* stickers and gifs have nothing to do. Null the rest of the time.
*/
private var mediaKeyboardTabs by mutableStateOf<Set<MediaKeyboardTab>?>(null)
/**
* How much room the expanded media keyboard has to leave the conversation. The toolbar and input
* panel are views, so only the fragment can measure them; the rest is message rows.
*/
private var minimumVisibleContentPx by mutableIntStateOf(MINIMUM_VISIBLE_MESSAGES_DP.dp)
private fun updateMinimumVisibleContent() {
if (view == null) {
return
}
minimumVisibleContentPx = MINIMUM_VISIBLE_MESSAGES_DP.dp + binding.toolbar.bottom.coerceAtLeast(0) + inputPanel.height
}
private val mediaKeyboardRepository: SignalMediaKeyboardRepository by lazy(LazyThreadSafetyMode.NONE) {
SignalMediaKeyboardRepository(requireContext(), recentEmojis)
}
private fun onMediaKeyboardAction(action: MediaKeyboardAction) {
when (action) {
is MediaKeyboardAction.EmojiSelected -> inputPanel.onEmojiSelected(action.emoji)
MediaKeyboardAction.Backspace -> inputPanel.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
is MediaKeyboardAction.StickerSelected -> {
viewLifecycleOwner.lifecycleScope.launch {
val record = withContext(Dispatchers.Default) {
SignalDatabase.stickers.getSticker(action.sticker.packId, action.sticker.stickerId.toInt(), false)
}
if (record != null) {
sendSticker(stickerRecord = record, clearCompose = false)
}
}
}
is MediaKeyboardAction.GifSelected -> {
val image = mediaKeyboardRepository.gifs.getGiphyImage(action.gif.id)
if (image != null) {
container.hideInput()
giphyMp4ViewModel.saveToBlob(image)
}
}
MediaKeyboardAction.StickerManagementClicked -> {
StickerManagementScreen.show(this)
container.hideInput()
}
// A child of this fragment so the dialog's own listener lookups land back here.
MediaKeyboardAction.StickerSearchClicked -> {
container.onHostWindowShown()
StickerSearchDialogFragment.show(childFragmentManager)
}
is MediaKeyboardAction.ViewStickerPackClicked -> {
startActivity(StickerPackPreviewActivityV2.createIntent(StickerPackId(action.packId), StickerPackKey(action.packKey)))
}
// A screen of its own rather than a window over this one, and picking a gif carries on into
// media send, so the keyboard has no reason to still be up on the way back.
MediaKeyboardAction.GifSearchClicked -> {
val recipientId = viewModel.recipientSnapshot?.id ?: return
container.hideInput()
conversationActivityResultContracts.launchGifSearch(recipientId, composeText.textTrimmed)
}
// Moves the input panel's toggle icon and persists the mode, so the keyboard opens on this tab
// next time.
is MediaKeyboardAction.TabSelected -> onKeyboardChanged(
when (action.tab) {
MediaKeyboardTab.EMOJI -> KeyboardPage.EMOJI
MediaKeyboardTab.STICKER -> KeyboardPage.STICKER
MediaKeyboardTab.GIF -> KeyboardPage.GIF
}
)
}
}
/** Turns a gif picked from the media keyboard into a blob the composer can attach. */
private val giphyMp4ViewModel: GiphyMp4ViewModel by activityViewModels { GiphyMp4ViewModel.Factory(isMms()) }
private var gifProgressDialog: AlertDialog? = null
private fun dismissGifProgressDialog() {
gifProgressDialog?.dismiss()
gifProgressDialog = null
}
private val container: ChatInputController by lazy(LazyThreadSafetyMode.NONE) {
ChatInputController(requireContext(), mediaKeyboardController)
}
@@ -725,13 +819,22 @@ class ConversationFragment :
conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false)
conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false)
// Read once here rather than in composition: it only matters when the keyboard's view model is
// first created, and it comes from disk.
val initialKeyboardTab = preferredKeyboardPage().toMediaKeyboardTab()
val composition = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
SignalTheme {
ChatScreen(
controller = mediaKeyboardController,
onEvent = ::onMediaKeyboardEvent,
onScaffoldAction = ::onKeyboardSheetAction,
mediaKeyboardRepository = mediaKeyboardRepository,
onMediaKeyboardAction = ::onMediaKeyboardAction,
mediaKeyboardTabs = mediaKeyboardTabs,
mediaKeyboardInitialTab = initialKeyboardTab,
minimumVisibleContentPx = minimumVisibleContentPx,
scrims = chatScrims,
isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE,
conversationView = conversationContent,
@@ -778,14 +881,43 @@ class ConversationFragment :
return origin
}
private fun onMediaKeyboardEvent(event: MediaKeyboardEvents) {
when (event) {
is MediaKeyboardEvents.SystemKeyboardVisibilityChanged -> container.onKeyboardVisibilityChanged(event.visible)
MediaKeyboardEvents.SystemKeyboardAnimationEnded -> container.onKeyboardAnimationEnded()
is MediaKeyboardEvents.SystemKeyboardHeightMeasured -> chatScreenViewModel.setKeyboardHeight(isLandscape(), event.heightPx)
is MediaKeyboardEvents.KeyboardShown -> container.onInputShown(event.key)
MediaKeyboardEvents.KeyboardHidden -> container.onInputHidden()
MediaKeyboardEvents.DismissedByBack -> Unit
private fun onKeyboardSheetAction(action: KeyboardSheetAction) {
when (action) {
is KeyboardSheetAction.SystemKeyboardVisibilityChanged -> {
// The toggle follows what is on screen: the system keyboard can cover one of ours, not just
// replace it.
inputPanel.setMediaKeyboardToggleOffersIme(!action.visible && container.isInputShowing)
// The open search field owns the keyboard, so it follows it in and out.
if (searchMenuItem?.isActionViewExpanded == true) {
val searchView = searchMenuItem?.actionView
if (action.visible && searchView?.hasFocus() == false) {
searchView.requestFocus()
} else if (!action.visible && searchView?.hasFocus() == true) {
searchView.clearFocus()
}
}
container.onKeyboardVisibilityChanged(action.visible)
}
is KeyboardSheetAction.SystemKeyboardHeightMeasured -> chatScreenViewModel.setKeyboardHeight(isLandscape(), action.heightPx)
is KeyboardSheetAction.KeyboardShown -> {
if (action.key == ChatKeyboards.Media) {
onShown()
}
}
KeyboardSheetAction.KeyboardHidden -> {
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
onHidden()
container.onInputHidden()
}
// Nothing hangs off these, but a scaffold has every reason to report them.
KeyboardSheetAction.SystemKeyboardAnimationEnded,
KeyboardSheetAction.DismissedByBack -> Unit
}
}
@@ -902,9 +1034,33 @@ class ConversationFragment :
}
applyToolbarPaddingRunnable = runnable
rv.post(runnable)
updateMinimumVisibleContent()
}
inputPanel.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateMinimumVisibleContent()
}
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
giphyMp4ViewModel.saveResultEvents.observe(viewLifecycleOwner) { result ->
when (result) {
is GiphyMp4SaveResult.Success -> {
dismissGifProgressDialog()
onGifSelectSuccess(result.blobUri, result.width, result.height)
}
is GiphyMp4SaveResult.Error -> {
dismissGifProgressDialog()
toast(R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, toastDuration = Toast.LENGTH_LONG)
}
else -> {
gifProgressDialog = SimpleProgressDialog.show(requireContext())
}
}
}
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
@@ -986,14 +1142,10 @@ class ConversationFragment :
override fun onDestroyView() {
viewModel.collapseAllEvents()
keyboardEvents?.let {
container.removeInputListener(it)
container.removeKeyboardStateListener(it)
}
keyboardEvents = null
// Fragment-scoped, so anything still waiting on a hide would outlive the binding.
container.clearListeners()
container.clearPendingActions()
dismissGifProgressDialog()
if (!requireActivity().isChangingConfigurations) {
(requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss()
@@ -1072,24 +1224,6 @@ class ConversationFragment :
clearFocusedItem()
}
override fun openEmojiSearch() {
val fragment = childFragmentManager.findFragmentByTag(EMOJI_SEARCH_FRAGMENT_TAG)
if (fragment == null) {
childFragmentManager.commit {
add(R.id.emoji_search_container, EmojiSearchFragment(), EMOJI_SEARCH_FRAGMENT_TAG)
}
}
}
override fun closeEmojiSearch() {
val fragment = childFragmentManager.findFragmentByTag(EMOJI_SEARCH_FRAGMENT_TAG)
if (fragment != null) {
childFragmentManager.commit(allowStateLoss = true) {
remove(fragment)
}
}
}
override fun onEmojiSelected(emoji: String?) {
if (emoji != null) {
inputPanel.onEmojiSelected(emoji)
@@ -1103,10 +1237,6 @@ class ConversationFragment :
}
}
override fun openStickerSearch() {
StickerSearchDialogFragment.show(childFragmentManager)
}
override fun onStickerSelected(sticker: StickerRecord) {
sendSticker(
stickerRecord = sticker,
@@ -1114,21 +1244,20 @@ class ConversationFragment :
)
}
override fun onStickerSearchDismissed() {
container.onHostWindowHidden()
}
override fun onStickerManagementClicked() {
StickerManagementScreen.show(this)
container.hideInput()
}
override fun isMms(): Boolean {
private fun isMms(): Boolean {
return false
}
override fun openGifSearch() {
val recipientId = viewModel.recipientSnapshot?.id ?: return
conversationActivityResultContracts.launchGifSearch(recipientId, composeText.textTrimmed)
}
override fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int) {
private fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int) {
setMedia(
uri = blobUri,
mediaType = SlideFactory.MediaType.from(AppDependencies.blobs.getMimeType(blobUri))!!,
@@ -1138,16 +1267,15 @@ class ConversationFragment :
)
}
override fun onShown() {
private fun onShown() {
inputPanel.mediaKeyboardListener.onShown()
}
override fun onHidden() {
private fun onHidden() {
inputPanel.mediaKeyboardListener.onHidden()
closeEmojiSearch()
}
override fun onKeyboardChanged(page: KeyboardPage) {
private fun onKeyboardChanged(page: KeyboardPage) {
inputPanel.mediaKeyboardListener.onKeyboardChanged(page)
}
@@ -1446,11 +1574,6 @@ class ConversationFragment :
dataObserver = DataObserver()
adapter.registerAdapterDataObserver(dataObserver!!)
keyboardEvents = KeyboardEvents().also {
container.addInputListener(it)
container.addKeyboardStateListener(it)
}
childFragmentManager.setFragmentResultListener(AttachmentKeyboardFragment.RESULT_KEY, viewLifecycleOwner, AttachmentKeyboardFragmentListener())
motionEventRelay.setDrain(MotionEventRelayDrain(this))
@@ -2505,21 +2628,28 @@ class ConversationFragment :
.addTo(disposables)
}
private fun initializeMediaKeyboard() {
val keyboardMode: TextSecurePreferences.MediaKeyboardMode = TextSecurePreferences.getMediaKeyboardMode(requireContext())
val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())
private fun KeyboardPage.toMediaKeyboardTab(): MediaKeyboardTab {
return when (this) {
KeyboardPage.EMOJI -> MediaKeyboardTab.EMOJI
KeyboardPage.STICKER -> MediaKeyboardTab.STICKER
KeyboardPage.GIF -> MediaKeyboardTab.GIF
}
}
keyboardPagerViewModel.resetPages()
inputPanel.showMediaKeyboardToggle(true)
val keyboardPage = when (keyboardMode) {
/** Which keyboard the toggle should offer, from the mode remembered across runs. */
private fun preferredKeyboardPage(): KeyboardPage {
return when (TextSecurePreferences.getMediaKeyboardMode(requireContext())) {
TextSecurePreferences.MediaKeyboardMode.EMOJI -> KeyboardPage.EMOJI
TextSecurePreferences.MediaKeyboardMode.STICKER -> KeyboardPage.STICKER
TextSecurePreferences.MediaKeyboardMode.GIF -> if (RemoteConfig.gifSearchAvailable) KeyboardPage.GIF else KeyboardPage.STICKER
}
}
inputPanel.setMediaKeyboardToggleMode(keyboardPage)
keyboardPagerViewModel.switchToPage(keyboardPage)
private fun initializeMediaKeyboard() {
val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())
inputPanel.showMediaKeyboardToggle(true)
inputPanel.setMediaKeyboardToggleMode(preferredKeyboardPage())
if (stickerIntro) {
TextSecurePreferences.setMediaKeyboardMode(requireContext(), TextSecurePreferences.MediaKeyboardMode.STICKER)
@@ -5228,9 +5358,9 @@ class ConversationFragment :
override fun onEnterEditMode() {
updateToggleButtonState()
previousPage = keyboardPagerViewModel.page().value
previousPages = keyboardPagerViewModel.pages().value
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
previousPage = preferredKeyboardPage()
mediaKeyboardTabs = setOf(MediaKeyboardTab.EMOJI)
// Also persists the mode, so read [previousPage] before this and restore it on the way out.
onKeyboardChanged(KeyboardPage.EMOJI)
stickerViewModel.onInputTextUpdated("")
updateLinkPreviewState()
@@ -5239,13 +5369,9 @@ class ConversationFragment :
override fun onExitEditMode() {
updateToggleButtonState()
draftViewModel.deleteMessageEditDraft()
if (previousPages != null) {
keyboardPagerViewModel.setPages(previousPages!!)
previousPages = null
}
if (previousPage != null) {
keyboardPagerViewModel.switchToPage(previousPage!!)
onKeyboardChanged(previousPage!!)
mediaKeyboardTabs = null
previousPage?.let {
onKeyboardChanged(it)
previousPage = null
}
updateLinkPreviewState()
@@ -5353,49 +5479,6 @@ class ConversationFragment :
}
}
private inner class KeyboardEvents :
ChatInputController.Listener,
ChatInputController.KeyboardStateListener {
override fun onInputShown(key: MediaKeyboardKey) {
if (key == ChatKeyboards.Media) {
onShown()
}
}
override fun onInputHidden() {
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
onHidden()
}
override fun onKeyboardShown() {
// The toggle follows what is on screen: the system keyboard can cover one of ours, not just
// replace it.
inputPanel.setMediaKeyboardToggleOffersIme(false)
if (searchMenuItem?.isActionViewExpanded == true && searchMenuItem?.actionView?.hasFocus() == false) {
searchMenuItem?.actionView?.requestFocus()
}
}
override fun onKeyboardHidden() {
inputPanel.setMediaKeyboardToggleOffersIme(container.isInputShowing)
if (searchMenuItem?.isActionViewExpanded == true && searchMenuItem?.actionView?.hasFocus() == true) {
searchMenuItem?.actionView?.clearFocus()
}
}
override fun onKeyboardAnimationEnded() {
if (view == null) {
return
}
if (!container.isKeyboardShowing) {
closeEmojiSearch()
}
}
}
//endregion
//region Event Bus
@@ -850,7 +850,7 @@ class ConversationViewModel(
data object Cancelled : PlaintextExportState
}
/** A media keyboard is absent by design: MediaKeyboardScaffold registers its own back handler. */
/** A media keyboard is absent by design: KeyboardSheetScaffold registers its own back handler. */
data class BackPressedState(
val isReactionDelegateShowing: Boolean = false,
val isSearchRequested: Boolean = false,
@@ -370,6 +370,17 @@ class StickerTables(
notifyStickerPackListeners()
}
fun updateStickerLastUsedTime(packId: String, stickerId: Int, lastUsed: Long) {
writableDatabase
.update(Sticker.TABLE_NAME)
.values(Sticker.LAST_USED to lastUsed)
.where("${Sticker.PACK_ID} = ? AND ${Sticker.STICKER_ID} = ? AND ${Sticker.COVER} = 0", packId, stickerId)
.run()
notifyStickerListeners()
notifyStickerPackListeners()
}
fun markPackAsInstalled(packId: String, notify: Boolean) {
val transitioned = updatePackInstalled(
db = databaseHelper.signalWritableDatabase,
@@ -830,7 +841,7 @@ class StickerTables(
val random: ByteArray
)
class StickerRecordReader(private val cursor: Cursor) : Closeable {
class StickerRecordReader(private val cursor: Cursor) : Closeable, Iterable<StickerRecord> {
fun getNext(): StickerRecord? {
if (!cursor.moveToNext()) {
@@ -853,9 +864,31 @@ class StickerTables(
)
}
fun asSequence(): Sequence<StickerRecord> = sequence {
var record = getNext()
while (record != null) {
yield(record)
record = getNext()
}
}
override fun close() {
cursor.close()
}
override fun iterator(): Iterator<StickerRecord> {
return ReaderIterator()
}
private inner class ReaderIterator : Iterator<StickerRecord> {
override fun hasNext(): Boolean {
return cursor.count != 0 && !cursor.isLast
}
override fun next(): StickerRecord {
return getNext() ?: throw NoSuchElementException()
}
}
}
class StickerPackRecordReader(private val cursor: Cursor) : Closeable, Iterable<StickerPackRecord> {
@@ -882,8 +915,10 @@ class StickerTables(
}
fun asSequence(): Sequence<StickerPackRecord> = sequence {
while (getNext() != null) {
yield(getCurrent())
var record = getNext()
while (record != null) {
yield(record)
record = getNext()
}
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.keyboard.sticker
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -120,6 +121,16 @@ class StickerSearchDialogFragment : DialogFragment(), KeyboardStickerListAdapter
override fun onStickerLongClicked(sticker: KeyboardStickerListAdapter.Sticker) = Unit
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
findListener<Callback>()?.onStickerSearchDismissed()
}
/** For a host that has to put something back once this window is gone. */
interface Callback {
fun onStickerSearchDismissed()
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
@@ -19,7 +19,7 @@ class StickerSearchRepository {
@WorkerThread
fun search(query: String): List<StickerRecord> {
if (query.isEmpty()) {
return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).readAll()
return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).use { reader -> reader.asSequence().toList() }
}
val maybeEmojiQuery: List<StickerRecord> = findStickersForEmoji(query)
@@ -36,19 +36,9 @@ class StickerSearchRepository {
return EmojiUtil.getAllRepresentations(searchEmoji)
.filterNotNull()
.map { candidate -> StickerRecordReader(stickerTables.getStickersByEmoji(candidate)).readAll() }
.map { candidate ->
StickerRecordReader(stickerTables.getStickersByEmoji(candidate)).use { reader -> reader.asSequence().toList() }
}
.flatten()
}
}
private fun StickerRecordReader.readAll(): List<StickerRecord> {
val stickers: MutableList<StickerRecord> = mutableListOf()
use { reader ->
var record: StickerRecord? = reader.getNext()
while (record != null) {
stickers.add(record)
record = reader.getNext()
}
}
return stickers
}
@@ -0,0 +1,98 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediakeyboard
import android.content.Context
import android.graphics.drawable.Drawable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.emoji.EmojiSource
import org.signal.emoji.EmojiUtil
import org.signal.mediakeyboard.data.EmojiCategoryPage
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
import org.signal.mediakeyboard.data.KeyboardEmoji
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* [EmojiKeyboardRepository] backed by the app's emoji data: [EmojiSource], the recent emoji store,
* and the emoji search index.
*/
class SignalEmojiKeyboardRepository(
private val context: Context,
private val recentEmoji: RecentEmojiPageModel
) : EmojiKeyboardRepository {
companion object {
private const val EMOJI_SEARCH_LIMIT = 50
}
override suspend fun getEmojiPages(): List<EmojiCategoryPage> {
return withContext(Dispatchers.Default) {
EmojiSource.latest.displayPages.mapNotNull { page ->
val category = EmojiKeyboardCategory.entries.firstOrNull { it.key == page.key } ?: return@mapNotNull null
EmojiCategoryPage(
category = category,
emoji = page.displayEmoji.map { emoji -> KeyboardEmoji(canonical = emoji.value, variations = emoji.variations) }
)
}
}
}
/** Stays on the caller's thread, since [RecentEmojiPageModel] is main-thread-only and shared with the host. */
override suspend fun getRecentEmoji(): List<KeyboardEmoji> {
return recentEmoji.emoji.map { KeyboardEmoji(canonical = it) }
}
/**
* Deliberately gated only on there being something to search for. The two-character minimum and
* the trailing-punctuation rule belong to inline `:word:` replacement, which cannot afford to
* guess at every keystroke; an explicit search field can. Blank queries never arrive here, since
* those show recents instead.
*/
override suspend fun search(query: String): List<KeyboardEmoji> {
if (query.isBlank()) {
return emptyList()
}
return withContext(Dispatchers.Default) {
val variationsByCanonical = EmojiSource.latest.canonicalToVariations
SignalDatabase.emojiSearch.query(query, EMOJI_SEARCH_LIMIT).map { emoji ->
KeyboardEmoji(canonical = emoji, variations = variationsByCanonical[emoji] ?: emptyList())
}
}
}
override suspend fun getPreferredVariations(): Map<String, String> {
return withContext(Dispatchers.Default) {
val emojiValues = SignalStore.emoji
EmojiSource.latest.canonicalToVariations.keys
.mapNotNull { canonical ->
val preferred = emojiValues.getPreferredVariation(canonical)
if (preferred != canonical) canonical to preferred else null
}
.toMap()
}
}
override fun setPreferredVariation(canonical: String, variation: String) {
SignalStore.emoji.setPreferredVariation(variation)
}
override suspend fun onEmojiUsed(emoji: String) {
recentEmoji.onCodePointSelected(emoji)
}
override fun getEmojiDrawable(emoji: String): Drawable? {
return if (SignalStore.settings.isPreferSystemEmoji) {
null
} else {
EmojiUtil.convertToDrawable(context, emoji)
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediakeyboard
import android.net.Uri
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.signal.core.util.JsonUtils
import org.signal.core.util.Result
import org.signal.mediakeyboard.data.GifFetchError
import org.signal.mediakeyboard.data.GifKeyboardRepository
import org.signal.mediakeyboard.data.GifPage
import org.signal.mediakeyboard.data.KeyboardGif
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl
import org.thoughtcrime.securesms.giph.model.GiphyImage
import org.thoughtcrime.securesms.giph.model.GiphyResponse
import org.thoughtcrime.securesms.net.ContentProxySelector
import java.io.IOException
/**
* [GifKeyboardRepository] backed by the Giphy API, fetched through the content proxy.
*
* Fetched [GiphyImage]s are cached by id so that the host can recover the full image metadata
* (e.g. the full-size mp4 url) for a selected [KeyboardGif] via [getGiphyImage].
*/
class SignalGifKeyboardRepository : GifKeyboardRepository {
companion object {
private val BASE_GIPHY_URI: Uri = Uri.parse("https://api.giphy.com/v1/gifs/")
.buildUpon()
.appendQueryParameter("api_key", BuildConfig.GIPHY_API_KEY)
.build()
private val TRENDING_URI: Uri = BASE_GIPHY_URI.buildUpon().appendPath("trending").build()
private val SEARCH_URI: Uri = BASE_GIPHY_URI.buildUpon().appendPath("search").build()
private const val IMAGE_CACHE_SIZE = 300
}
private val client: OkHttpClient by lazy {
AppDependencies.okHttpClient.newBuilder().proxySelector(ContentProxySelector()).build()
}
private val imagesById = object : LinkedHashMap<String, GiphyImage>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, GiphyImage>): Boolean = size > IMAGE_CACHE_SIZE
}
override suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError> {
return withContext(Dispatchers.IO) {
try {
val response = fetch(query.trim(), offset, limit)
val gifs = response.data
.filter { !it.gifUrl.isNullOrEmpty() && !it.mp4Url.isNullOrEmpty() && !it.mp4PreviewUrl.isNullOrEmpty() && !it.stillUrl.isNullOrEmpty() }
.map { image ->
synchronized(imagesById) {
imagesById[image.mp4Url] = image
}
KeyboardGif(
id = image.mp4Url,
still = ChunkedImageUrl(image.stillUrl),
mp4PreviewUri = Uri.parse(image.mp4PreviewUrl),
width = image.gifWidth,
height = image.gifHeight
)
}
Result.success(GifPage(gifs = gifs, hasMore = offset + response.data.size < response.pagination.totalCount))
} catch (e: IOException) {
Result.failure(GifFetchError.Network)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(GifFetchError.Unknown(e))
}
}
}
fun getGiphyImage(id: String): GiphyImage? {
return synchronized(imagesById) {
imagesById[id]
}
}
private fun fetch(query: String, offset: Int, limit: Int): GiphyResponse {
val url = (if (query.isEmpty()) TRENDING_URI else SEARCH_URI)
.buildUpon()
.appendQueryParameter("offset", offset.toString())
.appendQueryParameter("limit", limit.toString())
.apply {
if (query.isNotEmpty()) {
appendQueryParameter("q", query)
}
}
.build()
.toString()
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
return JsonUtils.fromJson(response.body.byteStream(), GiphyResponse::class.java)
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediakeyboard
import android.content.Context
import org.signal.mediakeyboard.MediaKeyboardTab
import org.signal.mediakeyboard.data.MediaKeyboardRepository
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* [MediaKeyboardRepository] backed by the app's real emoji, sticker, and gif data sources.
*/
class SignalMediaKeyboardRepository(context: Context, recentEmoji: RecentEmojiPageModel) : MediaKeyboardRepository {
private val appContext: Context = context.applicationContext
override val emoji: SignalEmojiKeyboardRepository = SignalEmojiKeyboardRepository(appContext, recentEmoji)
override val stickers: SignalStickerKeyboardRepository = SignalStickerKeyboardRepository(appContext)
override val gifs: SignalGifKeyboardRepository = SignalGifKeyboardRepository()
override suspend fun getAvailableTabs(): Set<MediaKeyboardTab> {
return setOfNotNull(
MediaKeyboardTab.EMOJI,
MediaKeyboardTab.STICKER,
MediaKeyboardTab.GIF.takeIf { RemoteConfig.gifSearchAvailable }
)
}
}
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediakeyboard
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.signal.core.models.database.StickerRecord
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.nullIfBlank
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.mediakeyboard.data.KeyboardSticker
import org.signal.mediakeyboard.data.KeyboardStickerPack
import org.signal.mediakeyboard.data.StickerKeyboardRepository
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.StickerTables.StickerPackRecordReader
import org.thoughtcrime.securesms.database.StickerTables.StickerRecordReader
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.MediaUtil
/**
* [StickerKeyboardRepository] backed by the app's sticker database.
*/
class SignalStickerKeyboardRepository(private val context: Context) : StickerKeyboardRepository {
companion object {
private const val RECENT_LIMIT = 24
}
override val allowStickerAnimation: Boolean = true
override fun observeStickerPacks(): Flow<List<KeyboardStickerPack>> {
return callbackFlow {
val observer = DatabaseObserver.Observer { trySend(Unit) }
AppDependencies.databaseObserver.registerStickerObserver(observer)
AppDependencies.databaseObserver.registerStickerPackObserver(observer)
trySend(Unit)
awaitClose {
AppDependencies.databaseObserver.unregisterObserver(observer)
}
}
.conflate()
.map { loadStickerPacks() }
.flowOn(Dispatchers.Default)
}
override fun onStickerUsed(sticker: KeyboardSticker) {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.stickers.updateStickerLastUsedTime(sticker.packId, sticker.stickerId.toInt(), System.currentTimeMillis())
}
}
private fun loadStickerPacks(): List<KeyboardStickerPack> {
val stickerTable = SignalDatabase.stickers
val packRecords = StickerPackRecordReader(stickerTable.getInstalledStickerPacks()).use { reader -> reader.asSequence().toList() }
val packs = packRecords.map { pack ->
val stickers = StickerRecordReader(stickerTable.getStickersForPack(pack.packId)).use { reader ->
reader.asSequence().map { it.toKeyboardSticker() }.toList()
}
KeyboardStickerPack(
id = pack.packId,
title = pack.title.nullIfBlank(),
cover = DecryptableUri(pack.cover.uri),
stickers = stickers
)
}
val recentStickers = StickerRecordReader(stickerTable.getRecentlyUsedStickers(RECENT_LIMIT)).use { reader -> reader.asSequence().toList() }
if (recentStickers.isEmpty()) {
return packs
}
val recentPack = KeyboardStickerPack(
id = StickerKeyboardRepository.RECENT_PACK_ID,
title = context.getString(R.string.StickerKeyboard__recently_used),
cover = null,
stickers = recentStickers.map { it.toKeyboardSticker() }
)
return listOf(recentPack) + packs
}
private fun StickerRecord.toKeyboardSticker(): KeyboardSticker {
return KeyboardSticker(
packId = packId,
packKey = packKey,
stickerId = stickerId.toLong(),
emoji = emoji.nullIfBlank(),
image = DecryptableUri(uri),
// Sticker packs only carry static WebP and animated APNG, so a PNG sticker is an APNG.
isAnimated = contentType == MediaUtil.IMAGE_PNG
)
}
}
@@ -289,13 +289,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/conversation_input_panel_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="emoji_search_container" />
<TextView
android:id="@+id/conversation_input_space_left"
android:layout_width="0dp"
@@ -304,7 +297,7 @@
android:paddingStart="5dp"
android:paddingEnd="5dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_input_panel_barrier"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="160/160 (1)"
@@ -320,14 +313,6 @@
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
tools:visibility="visible" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/emoji_search_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/polls_create"
android:layout_width="0dp"
@@ -107,6 +107,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Speaker(icon(R.drawable.symbol_speaker_24)),
SpeakerSlash(icon(R.drawable.symbol_speaker_slash_24)),
Sticker(icon(R.drawable.symbol_sticker_24)),
StickerPack(icon(R.drawable.symbol_stickerpack_24)),
Text(icon(R.drawable.symbol_text_24)),
TextSquare(icon(R.drawable.symbol_text_square_24)),
Transfer(icon(R.drawable.symbol_transfer_24)),
@@ -3,32 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose.mediakeyboard
package org.signal.core.ui.compose.keyboard
/**
* What a [MediaKeyboardScaffold] reports back. Requests flow the other way, through
* [MediaKeyboardController].
* Side effects emitted by a [KeyboardSheetScaffold] that need to be handled by the user of the
* component: which of its keyboards is up, and what the system keyboard is doing.
*/
sealed interface MediaKeyboardEvents {
/** @param key The keyboard now showing. */
data class KeyboardShown(val key: MediaKeyboardKey) : MediaKeyboardEvents
sealed interface KeyboardSheetAction {
data class KeyboardShown(val key: KeyboardSheetKey) : KeyboardSheetAction
/** None of ours is showing any more. */
data object KeyboardHidden : MediaKeyboardEvents
data object KeyboardHidden : KeyboardSheetAction
/** One of ours was dismissed by a back gesture rather than by a request. */
data object DismissedByBack : MediaKeyboardEvents
data object DismissedByBack : KeyboardSheetAction
/** @param visible Whether the system keyboard is up, on the target state rather than the animated one. */
data class SystemKeyboardVisibilityChanged(val visible: Boolean) : MediaKeyboardEvents
data class SystemKeyboardVisibilityChanged(val visible: Boolean) : KeyboardSheetAction
/** The system keyboard finished animating, in either direction. */
data object SystemKeyboardAnimationEnded : MediaKeyboardEvents
data object SystemKeyboardAnimationEnded : KeyboardSheetAction
/**
* A trustworthy system keyboard height was observed. The scaffold does not persist it.
*
* @param heightPx The measured height.
*/
data class SystemKeyboardHeightMeasured(val heightPx: Int) : MediaKeyboardEvents
data class SystemKeyboardHeightMeasured(val heightPx: Int) : KeyboardSheetAction
}
@@ -0,0 +1,172 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.keyboard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Asks a [KeyboardSheetScaffold] for a keyboard.
*
* Stable and safe to hold outside composition, so view code can call into it. Everything flowing the
* other way is a [KeyboardSheetAction].
*
* @param initialKeyboardHeightPx Height to use before one has been measured, typically persisted
* from an earlier run.
*/
@Stable
class KeyboardSheetController(initialKeyboardHeightPx: Int = 0) {
/** The keyboard currently up, or null when none of ours is. */
var current: KeyboardSheetKey? by mutableStateOf(null)
private set
/** Whether the system keyboard is up, from the target of the IME inset animation. */
var isSystemKeyboardVisible: Boolean by mutableStateOf(false)
internal set
/**
* True while the system keyboard has been asked for in place of one of ours but has yet to settle.
* Holds the space across that gap, which the IME service round trip would otherwise leave empty.
*/
var awaitingSystemKeyboard: Boolean by mutableStateOf(false)
internal set
/** How tall a keyboard of ours should be, replaced whenever a system keyboard is measured. */
var keyboardHeightPx: Int by mutableStateOf(initialKeyboardHeightPx)
internal set
/**
* Whether an expandable keyboard is, or is settling to, full height. Always false for keyboards
* that were not declared expandable.
*/
var isExpanded: Boolean by mutableStateOf(false)
internal set
/**
* Whether a window of the host's own -- a dialog with a field of its own, say -- is up in front of
* the sheet and owns the system keyboard while it is there, so the sheet stays put rather than
* giving way to it. Driven by [onHostWindowShown] and [onHostWindowHidden].
*/
var isBehindHostWindow: Boolean by mutableStateOf(false)
private set
private val _isEnteringText = MutableStateFlow(false)
/**
* Whether the keyboard that is up has taken text entry over, for content with a field of its own
* such as a search bar. Text entry cannot outlive the keyboard it is for, and only the scaffold is
* in a position to end it -- a drag, a collapse or a dismissal all take the room away -- so it
* lives here rather than in the content that asked for it.
*/
val isEnteringText: StateFlow<Boolean> = _isEnteringText.asStateFlow()
/** Where [expand] and [collapse] want the sheet; the scaffold drives it there. */
internal var expansionTarget: Boolean by mutableStateOf(false)
val isShowing: Boolean get() = current != null
fun show(key: KeyboardSheetKey) {
reset(key)
}
fun hide() {
reset(null)
}
/** Grows an expandable keyboard to full height. A no-op for any other keyboard. */
fun expand() {
expansionTarget = true
}
/**
* Returns an expanded keyboard to keyboard height, which also gives up text entry: a field has
* nowhere to go once the sheet is no taller than the system keyboard in front of it.
*/
fun collapse() {
_isEnteringText.value = false
expansionTarget = false
}
/**
* Takes text entry over on behalf of the keyboard that is up, growing the sheet to leave room for
* the system keyboard. Content calls this rather than asking for height, so it cannot end up
* holding a field with nowhere to put it.
*
* Only meaningful for a keyboard declared expandable, and a no-op when none of ours is up.
*/
fun beginTextEntry() {
if (current == null) {
return
}
_isEnteringText.value = true
expansionTarget = true
}
/** Gives text entry up, taking the system keyboard with it and returning to keyboard height. */
fun endTextEntry() {
collapse()
}
/** The host has put a window of its own up in front of the sheet. See [isBehindHostWindow]. */
fun onHostWindowShown() {
isBehindHostWindow = true
}
/** That window is gone, so the system keyboard is the sheet's business again. */
fun onHostWindowHidden() {
isBehindHostWindow = false
}
/** Closes [key] if it is already up, otherwise swaps to it. */
fun toggle(key: KeyboardSheetKey) {
if (current == key) hide() else show(key)
}
/**
* Puts ours away because the system keyboard is being brought up instead. Only holds the space if
* one of ours was up; with nothing to hand over, content should just follow the keyboard in.
*
* Also the one way out of [isEnteringText] that keeps the system keyboard: the host is taking it
* for a field of its own, so it is handed over rather than put away and summoned again.
*/
fun hideForSystemKeyboard() {
reset(null, awaitingSystem = current != null)
}
private fun reset(key: KeyboardSheetKey?, awaitingSystem: Boolean = false) {
awaitingSystemKeyboard = awaitingSystem
current = key
_isEnteringText.value = false
expansionTarget = false
}
}
/** @param initialKeyboardHeightPx See [KeyboardSheetController]. */
@Composable
fun rememberKeyboardSheetController(initialKeyboardHeightPx: Int = 0): KeyboardSheetController {
return remember { KeyboardSheetController(initialKeyboardHeightPx) }
}
/**
* The controller of the [KeyboardSheetScaffold] a keyboard is being shown by, so its content can
* drive the scaffold without the host having to thread callbacks down to it: taking text entry over
* for a field of its own, or reading whether it is at full height.
*
* Only present inside a scaffold's keyboard content.
*/
val LocalKeyboardSheetController = staticCompositionLocalOf<KeyboardSheetController> {
error("Not inside a KeyboardSheetScaffold's keyboard content.")
}
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.keyboard
/**
* Identifies a keyboard a [KeyboardSheetScaffold] can put up. Callers declare their own, so a screen
* only knows about the keyboards it offers.
*/
@JvmInline
value class KeyboardSheetKey(val name: String)
@@ -0,0 +1,587 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.keyboard
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.navigationBarsCompat
import org.signal.core.ui.compose.safeDrawingCompat
import org.signal.core.ui.compose.systemBarsCompat
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isHeightCompact
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
/** Shapes back gesture progress into travel. Matches the platform's IME hide curve. */
private val BACK_TRACKING_EASING = FastOutLinearInEasing
/** Settles the keyboard once a back gesture lets go. */
private val BACK_SETTLE_MOTION = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
)
/** How long to hold space for a system keyboard that was asked for but never appeared. */
private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds
private val SHEET_POSITIONAL_THRESHOLD = 56.dp
private val SHEET_VELOCITY_THRESHOLD = 125.dp
/** How far an expandable sheet dims what is behind it once at full height. */
private const val EXPANDED_SCRIM_ALPHA = 0.32f
/** How much taller than a keyboard an expandable sheet grows, short of what it is allowed to cover. */
private const val EXPANDED_HEIGHT_MULTIPLIER = 2f
/**
* Displays [content] alongside keyboards of our own that stand in for the system keyboard.
*
* Only one is ever up, and never alongside the system keyboard. The scaffold owns every window inset,
* handing [content] an already-inset space to lay out in.
*
* A keyboard declared expandable can be dragged past keyboard height to fill the window; the
* scaffold gives it a drag handle and dims [content] behind it as it grows. [content] keeps the size
* it had at keyboard height throughout, so nothing reflows while the sheet moves.
*
* @param controller Requests which keyboard is up. Stable, so view code may hold one.
* @param onAction Receives everything the host may need to act on.
* @param keyboardsProvider Declares the available keyboards.
* @param keyboardHeight Bounds on how tall a keyboard may be.
* @param adjustContentForInput False to let a keyboard cover [content] rather than resize it.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun KeyboardSheetScaffold(
controller: KeyboardSheetController,
onAction: (KeyboardSheetAction) -> Unit,
keyboardsProvider: KeyboardSheetScope.() -> Unit,
modifier: Modifier = Modifier,
keyboardHeight: KeyboardSheetHeight = KeyboardSheetHeight(),
adjustContentForInput: Boolean = true,
content: @Composable () -> Unit
) {
val registry = remember(keyboardsProvider) { KeyboardSheetRegistry().apply(keyboardsProvider) }
val currentOnAction by rememberUpdatedState(onAction)
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val imeAnimationTarget = WindowInsets.imeAnimationTarget
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val systemKeyboard = LocalSoftwareKeyboardController.current
val resources = LocalResources.current
val configuration = LocalConfiguration.current
val isHeightCompact = remember(resources, configuration) { resources.getWindowSizeClass().isHeightCompact }
val minimumHeightPx = with(density) { keyboardHeight.minimum.roundToPx() }
val topMarginPx = with(density) { keyboardHeight.topMargin.roundToPx() }
val activeKey = controller.current?.takeIf { registry.isEnabled(it) }
// The target, so a hide reads as gone the moment it is asked for rather than a whole animation later.
val systemKeyboardVisible = imeAnimationTarget.getBottom(density) > 0
// Whether the live inset has yet to catch up with the target. Deliberately not imeAnimationSource,
// which the platform leaves behind whenever an animation ends without a duration to run down, and
// which would then read as animating until the next keyboard came and went. Derived, so the frames
// the live inset walks through are not each a recomposition.
val systemKeyboardAnimating by remember(imeInsets, imeAnimationTarget, density) {
derivedStateOf { imeInsets.getBottom(density) != imeAnimationTarget.getBottom(density) }
}
SideEffect {
controller.isSystemKeyboardVisible = systemKeyboardVisible
}
// The controller outlives us. current is left set so a rebuilt view restores its keyboard.
DisposableEffect(controller) {
onDispose {
controller.isSystemKeyboardVisible = false
controller.awaitingSystemKeyboard = false
}
}
var hasReportedKeyboardVisibility by remember { mutableStateOf(false) }
LaunchedEffect(systemKeyboardVisible) {
if (hasReportedKeyboardVisibility) {
currentOnAction(KeyboardSheetAction.SystemKeyboardVisibilityChanged(systemKeyboardVisible))
}
hasReportedKeyboardVisibility = true
}
LaunchedEffect(controller.awaitingSystemKeyboard) {
if (controller.awaitingSystemKeyboard) {
delay(SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT)
controller.awaitingSystemKeyboard = false
}
}
// From the animation target, not the live inset, which walks down through every closing frame.
LaunchedEffect(imeAnimationTarget, density, minimumHeightPx, isHeightCompact) {
if (isHeightCompact) {
return@LaunchedEffect
}
snapshotFlow { imeAnimationTarget.getBottom(density) }
.filter { it > minimumHeightPx }
.distinctUntilChanged()
.collect {
controller.keyboardHeightPx = it
currentOnAction(KeyboardSheetAction.SystemKeyboardHeightMeasured(it))
}
}
// Keeps ours and the system keyboard from disagreeing in both directions, since the system keyboard
// can come and go without the scaffold being told. Read off the inset rather than
// systemKeyboardVisible, which is computed in composition and would leave a snapshotFlow emitting
// one captured value for good.
LaunchedEffect(imeAnimationTarget, density) {
snapshotFlow { imeAnimationTarget.getBottom(density) > 0 }
.distinctUntilChanged()
.dropWhile { visible -> !visible }
.collect { visible ->
if (visible) {
if (controller.current != null && !controller.isEnteringText.value && !controller.isBehindHostWindow) {
controller.hideForSystemKeyboard()
}
} else if (controller.isEnteringText.value) {
controller.endTextEntry()
}
}
}
// Text entry and the system keyboard it needs go away together, from here, because this is also
// where the sheet comes back down. Leaving it to the content's field would let the IME service take
// its own time noticing nothing is focused, trailing the keyboard out a beat behind the height.
LaunchedEffect(controller, systemKeyboard) {
controller.isEnteringText
.dropWhile { entering -> !entering }
.filter { entering -> !entering }
.collect {
// Unless the host is taking the keyboard over for a field of its own, which is the one way
// out of text entry that wants to keep it. [KeyboardSheetController.hideForSystemKeyboard]
// sets both in the one call, so this reads the same state the emission was made from.
if (!controller.awaitingSystemKeyboard) {
systemKeyboard?.hide()
}
}
}
// dropWhile rather than drop, so a composition that starts mid-animation still reports that settle.
LaunchedEffect(imeInsets, imeAnimationTarget, density) {
snapshotFlow { imeInsets.getBottom(density) == imeAnimationTarget.getBottom(density) }
.distinctUntilChanged()
.dropWhile { settled -> settled }
.filter { settled -> settled }
.collect {
controller.awaitingSystemKeyboard = false
currentOnAction(KeyboardSheetAction.SystemKeyboardAnimationEnded)
}
}
val heightPx = keyboardHeight.resolve(
preferredPx = controller.keyboardHeightPx,
windowHeightPx = windowHeightPx,
minimumPx = minimumHeightPx,
topMarginPx = topMarginPx
)
val height = with(density) { heightPx.toDp() }
val backProgress = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
// Read through rememberUpdatedState rather than captured: these lambdas outlive the composition
// that built the sheet, and keying the remember on density instead would rebuild the sheet at its
// initial value, putting a keyboard away because the font scale changed.
val currentDensity by rememberUpdatedState(density)
val sheetState = remember {
SheetState(
skipPartiallyExpanded = false,
positionalThreshold = { with(currentDensity) { SHEET_POSITIONAL_THRESHOLD.toPx() } },
velocityThreshold = { with(currentDensity) { SHEET_VELOCITY_THRESHOLD.toPx() } },
initialValue = SheetValue.Hidden,
skipHiddenState = false
)
}
val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState)
// This just makes sure the previously visible state doesn't go away too early while we're mid swap.
var visibleKey by remember { mutableStateOf<KeyboardSheetKey?>(null) }
// Set while one of ours is coming up in place of the system keyboard. The sheet's rise is not that
// keyboard's fall, so the space is held flat for the crossing rather than tracking either curve.
var handingOverFromSystemKeyboard by remember { mutableStateOf(false) }
val expandable = registry.isExpandable(visibleKey)
// BottomSheetScaffold measures its anchors from the sheet, so the sheet is always as tall as it
// could ever need to be and the part below the fold simply hangs off the bottom of the window.
var scaffoldHeightPx by remember { mutableIntStateOf(0) }
val layoutHeightPx = if (scaffoldHeightPx > 0) scaffoldHeightPx else windowHeightPx
val minimumContentPx = with(density) { keyboardHeight.minimumContentVisible.roundToPx() }
val windowLimitPx = (layoutHeightPx - WindowInsets.statusBars.getTop(density) - minimumContentPx).coerceAtLeast(heightPx)
val expandedHeightPx = (heightPx * EXPANDED_HEIGHT_MULTIPLIER).roundToInt().coerceIn(heightPx, windowLimitPx)
val sheetHeightPx = if (expandable) expandedHeightPx else heightPx
val sheetHeight = with(density) { sheetHeightPx.toDp() }
/**
* How much of the sheet the window actually shows. Reads the live sheet offset, so anything that
* calls it from layout or draw follows a drag frame by frame.
*/
val visibleSheetHeightPx = {
val offset = try {
// Throws until the sheet has been measured for the first time.
sheetState.requireOffset()
} catch (ignored: IllegalStateException) {
null
}
if (offset == null) {
if (visibleKey != null) heightPx else 0
} else {
(layoutHeightPx - offset).roundToInt().coerceIn(0, sheetHeightPx)
}
}
/** 0 at keyboard height, 1 at full height. */
val expansionFraction = {
val travelPx = sheetHeightPx - heightPx
if (!expandable || travelPx <= 0) 0f else ((visibleSheetHeightPx() - heightPx).toFloat() / travelPx).coerceIn(0f, 1f)
}
/**
* Where the sheet belongs, from what has been asked for rather than from where it happens to be.
* Every request folds into this one value: a keyboard shown, swapped or put away, and an expansion
* asked for or given up.
*/
val sheetTarget = when {
activeKey == null -> SheetValue.Hidden
expandable && controller.expansionTarget -> SheetValue.Expanded
else -> SheetValue.PartiallyExpanded
}
// The one place the sheet is driven from. Keyed on the target, so every change comes through here
// in order and a new one cancels its own predecessor rather than queueing behind it.
LaunchedEffect(sheetTarget, activeKey) {
val key = activeKey
if (key == null) {
if (visibleKey == null) {
return@LaunchedEffect
}
// A gesture may already have carried it off screen; hold that until the hide completes.
handingOverFromSystemKeyboard = false
sheetState.hide()
backProgress.snapTo(0f)
visibleKey = null
currentOnAction(KeyboardSheetAction.KeyboardHidden)
return@LaunchedEffect
}
val arriving = visibleKey != key
if (arriving) {
if (visibleKey != null) {
currentOnAction(KeyboardSheetAction.KeyboardHidden)
}
handingOverFromSystemKeyboard = systemKeyboardVisible || systemKeyboardAnimating
visibleKey = key
backProgress.snapTo(0f)
}
when {
sheetTarget == SheetValue.Expanded && sheetState.hasExpandedState -> sheetState.expand()
sheetState.currentValue == SheetValue.Hidden -> sheetState.show()
else -> sheetState.partialExpand()
}
if (arriving) {
handingOverFromSystemKeyboard = false
currentOnAction(KeyboardSheetAction.KeyboardShown(key))
}
}
// Picks up drags as well as requests, so the controller reports where the sheet actually went.
LaunchedEffect(sheetState) {
snapshotFlow { sheetState.targetValue == SheetValue.Expanded }
.distinctUntilChanged()
.collect { expanded ->
controller.isExpanded = expanded
if (expanded) {
controller.expansionTarget = true
} else {
controller.endTextEntry()
}
}
}
// A swipe can carry the sheet away without anyone having asked it to, leaving the controller
// believing a keyboard is still up and content still holding space for one.
LaunchedEffect(sheetState) {
snapshotFlow { sheetState.currentValue }
.filter { it == SheetValue.Hidden }
.collect {
if (controller.isShowing) {
controller.hide()
}
}
}
PredictiveBackHandler(enabled = activeKey != null) { progress ->
try {
progress.collect { backEvent -> backProgress.snapTo(BACK_TRACKING_EASING.transform(backEvent.progress)) }
backProgress.animateTo(1f, BACK_SETTLE_MOTION)
controller.hide()
onAction(KeyboardSheetAction.DismissedByBack)
} catch (cancelled: CancellationException) {
// PredictiveBackHandler cancels this job, so the unwind must run somewhere that outlives it.
scope.launch { backProgress.animateTo(0f, BACK_SETTLE_MOTION) }
throw cancelled
}
}
val systemKeyboardTakingOverSpace = activeKey == null &&
(controller.awaitingSystemKeyboard || (systemKeyboardVisible && systemKeyboardAnimating))
val claimedBottomPx = {
if (systemKeyboardTakingOverSpace || handingOverFromSystemKeyboard) {
heightPx
} else if (activeKey != null || visibleKey != null) {
// Tracks the sheet rather than its resting height, so content rides along with a drag.
(visibleSheetHeightPx() * (1f - backProgress.value)).roundToInt().coerceAtLeast(0)
} else {
0
}
}
var ancestorConsumedBottomPx by remember { mutableIntStateOf(0) }
val safeDrawingInsets = WindowInsets.safeDrawingCompat
val windowInsets = if (adjustContentForInput) {
safeDrawingInsets
} else {
WindowInsets.systemBarsCompat.add(WindowInsets.displayCutout)
}
val containerColor = registry.containerColorFor(visibleKey).takeOrElse { MaterialTheme.colorScheme.surfaceContainerLow }
val scrimColor = MaterialTheme.colorScheme.scrim
val sheetShape = if (expandable) {
MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
} else {
RectangleShape
}
// Keyed, because expansionFraction closes over this composition's heights and visible key; an
// unkeyed remember would hold the first composition's, from before anything was up.
val scrimShowing by remember(expandable, heightPx, sheetHeightPx, layoutHeightPx, visibleKey) {
derivedStateOf { expansionFraction() > 0f }
}
Box(
modifier = modifier
.fillMaxSize()
.onSizeChanged { scaffoldHeightPx = it.height }
) {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = height,
sheetShape = sheetShape,
sheetDragHandle = null,
sheetSwipeEnabled = expandable,
sheetContainerColor = Color.Transparent,
sheetTonalElevation = 0.dp,
sheetShadowElevation = 0.dp,
containerColor = Color.Transparent,
sheetContent = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(sheetHeight)
// Off the bottom by whatever is actually on screen, which an expanded sheet outgrows.
.graphicsLayer { translationY = backProgress.value * visibleSheetHeightPx() }
.background(containerColor)
) {
Column(
modifier = Modifier
.layout { measurable, constraints ->
// Only the part of the sheet the window shows is worth laying content out in, and
// never less than a keyboard: below that the sheet is on its way out, not resizing.
val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight)
val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible))
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(0, 0)
}
}
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
) {
if (expandable) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
CompositionLocalProvider(LocalKeyboardSheetController provides controller) {
registry.contentFor(visibleKey)?.invoke()
}
}
}
}
}
) { _ ->
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) }
.windowInsetsPadding(windowInsets)
.layout { measurable, constraints ->
// Window insets are already out of these constraints; take only the excess claim.
val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0)
val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0
val available = (constraints.maxHeight - extraPx).coerceAtLeast(0)
val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available))
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(0, 0)
}
}
) {
content()
}
if (expandable) {
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind { drawRect(color = scrimColor, alpha = EXPANDED_SCRIM_ALPHA * expansionFraction()) }
.then(
if (scrimShowing) {
// Collapses rather than dismisses: the dim means the sheet has grown past keyboard
// height, and keyboard height is where it belongs the rest of the time, so undoing
// the growth is what the gesture is for. Dismissing would also be indiscriminate,
// since this covers the whole of [content] -- a host's own text field included.
Modifier.pointerInput(Unit) { detectTapGestures { controller.collapse() } }
} else {
Modifier
}
)
)
}
}
}
}
}
/**
* Bounds on how tall a keyboard may be. The height it wants comes from
* [KeyboardSheetController.keyboardHeightPx].
*
* Holds a lambda, so callers should [remember] it or the scaffold cannot skip recomposition.
*
* @param minimum Floor, used before any keyboard has been measured.
* @param topMargin Kept clear of the top of the window, capping the height.
* @param minimumContentVisible How much of the content an expanded keyboard must leave in view,
* capping how far it can grow. Only the host can know what this should be, since it covers
* whatever chrome the content puts between its edges and the part worth keeping visible.
* @param overrideForWindow Derives a height from the window instead, for windows unlike the one a
* keyboard was measured in.
*/
@Immutable
data class KeyboardSheetHeight(
val minimum: Dp = 260.dp,
val topMargin: Dp = 170.dp,
val minimumContentVisible: Dp = 0.dp,
val overrideForWindow: ((windowHeightPx: Int) -> Int)? = null
) {
internal fun resolve(preferredPx: Int, windowHeightPx: Int, minimumPx: Int, topMarginPx: Int): Int {
if (windowHeightPx <= 0) {
return maxOf(preferredPx, minimumPx)
}
val maximumPx = (windowHeightPx - topMarginPx).coerceAtLeast(minimumPx)
overrideForWindow?.let { return it(windowHeightPx).coerceAtMost(maximumPx) }
return preferredPx.coerceIn(minimumPx, maximumPx)
}
}
@@ -0,0 +1,67 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.keyboard
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** Declares which keyboards a [KeyboardSheetScaffold] offers. */
interface KeyboardSheetScope {
/**
* Offers the keyboard identified by [key].
*
* @param enabled False to keep the key known but refuse requests for it.
* @param containerColor Fills the sheet, navigation bar included, so it should match whatever
* [content] paints its edges with. Unspecified falls back to the scaffold's surface.
* @param expandable True to let this keyboard grow past keyboard height to fill the window. Gives
* the sheet a drag handle, lets it be swiped, and dims what is behind it as it grows.
* @param content The keyboard itself.
*/
fun keyboard(
key: KeyboardSheetKey,
enabled: Boolean = true,
containerColor: Color = Color.Unspecified,
expandable: Boolean = false,
content: @Composable () -> Unit
)
}
internal class KeyboardSheetRegistry : KeyboardSheetScope {
private val entries = LinkedHashMap<KeyboardSheetKey, Entry>()
override fun keyboard(
key: KeyboardSheetKey,
enabled: Boolean,
containerColor: Color,
expandable: Boolean,
content: @Composable () -> Unit
) {
entries[key] = Entry(enabled, containerColor, expandable, content)
}
fun isEnabled(key: KeyboardSheetKey?): Boolean = key != null && entries[key]?.enabled == true
fun contentFor(key: KeyboardSheetKey?): (@Composable () -> Unit)? {
return enabledEntry(key)?.content
}
fun containerColorFor(key: KeyboardSheetKey?): Color {
return enabledEntry(key)?.containerColor ?: Color.Unspecified
}
fun isExpandable(key: KeyboardSheetKey?): Boolean = enabledEntry(key)?.expandable == true
private fun enabledEntry(key: KeyboardSheetKey?): Entry? {
return key?.let { entries[it] }?.takeIf { it.enabled }
}
private class Entry(
val enabled: Boolean,
val containerColor: Color,
val expandable: Boolean,
val content: @Composable () -> Unit
)
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M14.75 22.4c2.85 0 8.65-5.8 8.65-8.65 0-4.08-2.82-7.5-6.63-8.41-0.59-3.07-2.76-4.58-5.98-4.16l-5.45 0.71C1.95 2.33 0.24 4.55 0.68 7.95l0.72 5.46c0.44 3.33 2.58 5.02 5.85 4.67a8.66 8.66 0 0 0 7.49 4.32ZM3.19 13.17l-0.72-5.46c-0.31-2.4 0.7-3.73 3.1-4.04l5.45-0.71c2.06-0.27 3.32 0.43 3.84 2.14h-0.12a8.646 8.646 0 0 0-8.26 11.22c-1.94 0.02-3.02-1.01-3.3-3.15Zm4.71 0.58c0-3.78 3.07-6.85 6.85-6.85 3.59 0 6.54 2.77 6.83 6.29a6.419 6.419 0 0 0-7.39 7.39c-3.52-0.29-6.29-3.24-6.29-6.83m8 5.78c0-2.56 2.07-4.63 4.63-4.63 0.22 0 0.44 0.02 0.66 0.05-0.98 1.75-3.49 4.27-5.24 5.24-0.03-0.21-0.05-0.43-0.05-0.66m0 0"/>
</vector>
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.signal.mediakeyboard.demo"
defaultConfig {
applicationId = "org.signal.mediakeyboard.demo"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Media keyboard feature module
implementation(project(":feature:media-keyboard"))
// Core modules
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":lib:glide"))
implementation(libs.glide.glide)
// Core AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM
platform(libs.androidx.compose.bom).let { composeBom ->
implementation(composeBom)
androidTestImplementation(composeBom)
}
// Compose dependencies
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling.core)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.androidx.compose.material.icons.extended)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
// Testing
androidTestImplementation(testLibs.junit.junit)
androidTestImplementation(testLibs.androidx.test.runner)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
+21
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
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".MediaKeyboardDemoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MediaKeyboardDemo">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediakeyboard.demo.main.MainScreen
import org.signal.mediakeyboard.demo.main.MainScreenViewModel
class MainActivity : ComponentActivity() {
private val viewModel: MainScreenViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val repository = (application as MediaKeyboardDemoApplication).repository
setContent {
SignalTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val state by viewModel.state.collectAsStateWithLifecycle()
MainScreen(
state = state,
onEvent = viewModel::onEvent,
repository = repository
)
}
}
}
}
}
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo
import android.app.Application
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.mediakeyboard.demo.data.DemoMediaKeyboardRepository
import org.thoughtcrime.securesms.mms.RegisterGlideComponents
import org.thoughtcrime.securesms.mms.SignalGlideModule
class MediaKeyboardDemoApplication : Application() {
lateinit var repository: DemoMediaKeyboardRepository
private set
override fun onCreate() {
super.onCreate()
Log.initialize(AndroidLogger)
SignalGlideModule.registerGlideComponents = object : RegisterGlideComponents {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) = Unit
}
CoreUiDependencies.init(
this,
object : CoreUiDependencies.Provider {
override fun providePackageId(): String = BuildConfig.APPLICATION_ID
override fun provideIsIncognitoKeyboardEnabled(): Boolean = false
override fun provideIsScreenSecurityEnabled(): Boolean = false
}
)
repository = DemoMediaKeyboardRepository(this)
}
}
@@ -0,0 +1,205 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.data
import org.signal.mediakeyboard.data.EmojiCategoryPage
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
import org.signal.mediakeyboard.data.KeyboardEmoji
/**
* An in-memory emoji source with a small but representative data set, including skin tone
* variations and a keyword search index.
*/
class DemoEmojiKeyboardRepository : EmojiKeyboardRepository {
private val skinTones = listOf("🏻", "🏼", "🏽", "🏾", "🏿")
private fun toned(base: String): KeyboardEmoji {
return KeyboardEmoji(canonical = base, variations = listOf(base) + skinTones.map { base + it })
}
private fun plain(emoji: String): KeyboardEmoji = KeyboardEmoji(emoji)
private val pages: List<EmojiCategoryPage> = listOf(
EmojiCategoryPage(
category = EmojiKeyboardCategory.PEOPLE,
emoji = listOf(
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩",
"😘", "😋", "😛", "😜", "🤪", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😶", "😏", "😒",
"🙄", "😬", "😌", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤢", "🥵", "🥶", "🥴", "🤯", "🥳", "😎",
"🤓", "🧐", "😕", "😟", "🙁", "😮", "😯", "😲", "😳", "🥺", "😨", "😰", "😥", "😢", "😭", "😱",
"😖", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬"
).map(::plain) + listOf(
toned("👍"), toned("👎"), toned("👋"), toned("🙏"), toned("👏"), toned("💪"), toned("🤞"),
toned("🤘"), toned("👌"), toned("👈"), toned("👉"), toned("👆"), toned("👇"), toned("✋"),
toned("🤚"), toned("👊"), toned("✊"), toned("🤛"), toned("🤜")
)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.NATURE,
emoji = listOf(
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
"🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🐺", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐢", "🐍",
"🐙", "🦀", "🐠", "🐟", "🐬", "🐳", "🦈", "🔥", "🌸", "💐", "🌹", "🌺", "🌻", "🌼", "🌷", "🌱",
"🌲", "🌳", "🌴", "🌵", "🍀", "🌈", "⭐", "🌙", "☀️", "⛅", "❄️", "💧"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.FOODS,
emoji = listOf(
"🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅",
"🍆", "🥑", "🥦", "🥒", "🌶️", "🌽", "🥕", "🥔", "🥐", "🍞", "🥖", "🥨", "🧀", "🍳", "🥞", "🧇",
"🥓", "🍗", "🌭", "🍔", "🍟", "🍕", "🥪", "🌮", "🌯", "🥗", "🍝", "🍜", "🍣", "🍱", "🍤", "🍚",
"🍦", "🍧", "🍨", "🍩", "🍪", "🎂", "🍰", "🧁", "🍫", "🍬", "🍭", "☕", "🍵", "🥤", "🍺", "🍷"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.ACTIVITY,
emoji = listOf(
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🏒", "🥅", "⛳", "🏹", "🎣",
"🥊", "🥋", "🛹", "⛸️", "🎿", "🎪", "🎉", "🎊", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸",
"🎻", "🎲", "🎯", "🎳", "🎮", "🧩"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.PLACES,
emoji = listOf(
"🚗", "🚕", "🚙", "🚌", "🚓", "🚑", "🚒", "🚚", "🚜", "🛴", "🚲", "🛵", "🚨", "🚄", "🚅", "🚂",
"✈️", "🛫", "🚀", "🛸", "🚁", "⛵", "🚤", "🚢", "⚓", "🗼", "🏰", "🎡", "🎢", "🎠", "⛲", "🏖️",
"🌋", "🗽", "🏠", "🏢", "🏥", "🏦", "🏫", "⛪"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.OBJECTS,
emoji = listOf(
"⌚", "📱", "💻", "⌨️", "🖥️", "🖨️", "📷", "📸", "📹", "🎥", "📞", "☎️", "📺", "📻", "⏰", "⌛",
"🔋", "🔌", "💡", "🔦", "🕯️", "💸", "💵", "💰", "💳", "💎", "🔧", "🔨", "🛠️", "⚙️", "🔮", "🔭",
"🔬", "💊", "💉", "🎁", "🎈", "🎀", "📚", "📖", "✏️", "📌"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.SYMBOLS,
emoji = listOf(
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖",
"💘", "💝", "☮️", "☯️", "✅", "❌", "❓", "❗", "💯", "♻️", "💤", "🆒", "🆕", "🆓", "🔴", "🟠",
"🟡", "🟢", "🔵", "🟣", "⚫", "⚪", "💬", "💭"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.FLAGS,
emoji = listOf(
"🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️‍🌈", "🏴‍☠️", "🇺🇸", "🇨🇦", "🇬🇧", "🇫🇷", "🇩🇪", "🇮🇹", "🇪🇸", "🇵🇹", "🇧🇷",
"🇲🇽", "🇯🇵", "🇰🇷", "🇨🇳", "🇮🇳", "🇦🇺", "🇳🇿", "🇿🇦", "🇳🇬", "🇪🇬", "🇸🇪", "🇳🇴", "🇩🇰", "🇫🇮", "🇺🇦", "🇵🇱"
).map(::plain)
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.EMOTICONS,
emoji = listOf(
":-)", ";-)", ":-(", ":-P", ":-D", ":-O", ":-*", "<3", ":-/", ":-|", "^_^", "T_T"
).map(::plain)
)
)
private val searchIndex: Map<String, List<String>> = mapOf(
"smile" to listOf("😀", "😃", "😄", "🙂", "😊"),
"laugh" to listOf("😂", "🤣", "😆"),
"happy" to listOf("😀", "😃", "😄", "😁", "🥳"),
"sad" to listOf("😢", "😭", "🙁", "😞", "💔"),
"cry" to listOf("😢", "😭"),
"angry" to listOf("😡", "😠", "🤬"),
"love" to listOf("❤️", "😍", "🥰", "😘", "💕", "💖"),
"heart" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "💔", "💕", "💖"),
"kiss" to listOf("😘", ":-*"),
"wink" to listOf("😉", ";-)"),
"cool" to listOf("😎", "🆒"),
"party" to listOf("🥳", "🎉", "🎊", "🎈"),
"celebrate" to listOf("🎉", "🎊", "🥳", "🍾"),
"thumbs up" to listOf("👍"),
"thumbs down" to listOf("👎"),
"wave" to listOf("👋"),
"clap" to listOf("👏"),
"pray" to listOf("🙏"),
"strong" to listOf("💪"),
"fire" to listOf("🔥"),
"dog" to listOf("🐶"),
"cat" to listOf("🐱"),
"fox" to listOf("🦊"),
"unicorn" to listOf("🦄"),
"flower" to listOf("🌸", "🌹", "🌺", "🌻", "🌷", "💐"),
"star" to listOf("⭐", "🤩"),
"sun" to listOf("☀️"),
"moon" to listOf("🌙"),
"rain" to listOf("💧", "🌈"),
"snow" to listOf("❄️"),
"pizza" to listOf("🍕"),
"burger" to listOf("🍔"),
"cake" to listOf("🎂", "🍰", "🧁"),
"coffee" to listOf("☕"),
"beer" to listOf("🍺"),
"car" to listOf("🚗", "🚕", "🚙"),
"plane" to listOf("✈️", "🛫"),
"rocket" to listOf("🚀"),
"music" to listOf("🎤", "🎧", "🎼", "🎸"),
"game" to listOf("🎮", "🎲"),
"ball" to listOf("⚽", "🏀", "🏈", "⚾", "🎾"),
"money" to listOf("💸", "💵", "💰", "🤑"),
"phone" to listOf("📱", "📞", "☎️"),
"check" to listOf("✅"),
"hundred" to listOf("💯"),
"sleep" to listOf("😴", "💤"),
"flag" to listOf("🏁", "🚩", "🏳️", "🏴")
)
private val emojiByCanonical: Map<String, KeyboardEmoji> = pages
.flatMap { it.emoji }
.associateBy { it.canonical }
private val recents = ArrayDeque(listOf("😂", "❤️", "👍", "😍", "🔥", "🎉", "😭", "🙏"))
private val preferredVariations = mutableMapOf<String, String>()
override suspend fun getEmojiPages(): List<EmojiCategoryPage> = pages
override suspend fun getRecentEmoji(): List<KeyboardEmoji> {
return recents.map { emojiByCanonical[it] ?: KeyboardEmoji(it) }
}
override suspend fun search(query: String): List<KeyboardEmoji> {
val normalized = query.trim().lowercase()
if (normalized.isEmpty()) {
return emptyList()
}
return searchIndex
.filterKeys { it.contains(normalized) }
.values
.flatten()
.distinct()
.map { emojiByCanonical[it] ?: KeyboardEmoji(it) }
}
override suspend fun getPreferredVariations(): Map<String, String> = preferredVariations.toMap()
override fun setPreferredVariation(canonical: String, variation: String) {
if (canonical == variation) {
preferredVariations.remove(canonical)
} else {
preferredVariations[canonical] = variation
}
}
override suspend fun onEmojiUsed(emoji: String) {
recents.remove(emoji)
recents.addFirst(emoji)
while (recents.size > MAX_RECENTS) {
recents.removeLast()
}
}
companion object {
private const val MAX_RECENTS = 24
}
}
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.data
import android.content.Context
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.RawRes
import kotlinx.coroutines.delay
import org.signal.core.util.Result
import org.signal.mediakeyboard.data.GifFetchError
import org.signal.mediakeyboard.data.GifKeyboardRepository
import org.signal.mediakeyboard.data.GifPage
import org.signal.mediakeyboard.data.KeyboardGif
import org.signal.mediakeyboard.demo.R
/**
* A fake gif source backed by bundled mp4 clips. Simulates network latency and pagination.
* Search for "error" to see the failure state, or "empty" to see the no-results state.
*/
class DemoGifKeyboardRepository(private val context: Context) : GifKeyboardRepository {
private data class Template(
@field:RawRes val video: Int,
@field:DrawableRes val still: Int,
val width: Int,
val height: Int
)
private val templates = listOf(
Template(R.raw.demo_gif_1, R.drawable.demo_gif_still_1, 240, 240),
Template(R.raw.demo_gif_2, R.drawable.demo_gif_still_2, 320, 240),
Template(R.raw.demo_gif_3, R.drawable.demo_gif_still_3, 240, 320),
Template(R.raw.demo_gif_4, R.drawable.demo_gif_still_4, 320, 180),
Template(R.raw.demo_gif_5, R.drawable.demo_gif_still_5, 180, 320),
Template(R.raw.demo_gif_6, R.drawable.demo_gif_still_6, 240, 180)
)
override suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError> {
delay(600)
val normalized = query.trim().lowercase()
if (normalized == "error") {
return Result.failure(GifFetchError.Network)
}
if (normalized == "empty") {
return Result.success(GifPage(gifs = emptyList(), hasMore = false))
}
val total = if (normalized.isEmpty()) 60 else 40
if (offset >= total) {
return Result.success(GifPage(gifs = emptyList(), hasMore = false))
}
val count = minOf(limit, total - offset)
val seed = normalized.hashCode()
val gifs = (0 until count).map { i ->
val template = templates[(seed + offset + i).mod(templates.size)]
KeyboardGif(
id = "$normalized:${offset + i}",
still = template.still,
mp4PreviewUri = Uri.parse("android.resource://${context.packageName}/${template.video}"),
width = template.width,
height = template.height
)
}
return Result.success(GifPage(gifs = gifs, hasMore = offset + count < total))
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.data
import android.content.Context
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
import org.signal.mediakeyboard.data.GifKeyboardRepository
import org.signal.mediakeyboard.data.MediaKeyboardRepository
import org.signal.mediakeyboard.data.StickerKeyboardRepository
class DemoMediaKeyboardRepository(context: Context) : MediaKeyboardRepository {
override val emoji: EmojiKeyboardRepository = DemoEmojiKeyboardRepository()
override val stickers: StickerKeyboardRepository = DemoStickerKeyboardRepository(context)
override val gifs: GifKeyboardRepository = DemoGifKeyboardRepository(context)
}
@@ -0,0 +1,127 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.annotation.DrawableRes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.signal.mediakeyboard.data.KeyboardSticker
import org.signal.mediakeyboard.data.KeyboardStickerPack
import org.signal.mediakeyboard.data.StickerKeyboardRepository
import org.signal.mediakeyboard.demo.R
/**
* An in-memory sticker source backed by bundled vector drawables. The vectors are rasterized to
* bitmaps because Glide has no disk-cache encoder for VectorDrawable.
*/
class DemoStickerKeyboardRepository(private val context: Context) : StickerKeyboardRepository {
private data class DemoSticker(
val name: String,
@field:DrawableRes val image: Int,
val emoji: String,
val packId: String,
val id: Long
)
private val demoStickers: List<DemoSticker> = buildList {
var id = 0L
fun sticker(name: String, image: Int, emoji: String, packId: String) {
add(DemoSticker(name, image, emoji, packId, id++))
}
sticker("heart", R.drawable.sticker_heart, "❤️", SHAPES_PACK_ID)
sticker("star", R.drawable.sticker_star, "⭐", SHAPES_PACK_ID)
sticker("circle", R.drawable.sticker_circle, "🔵", SHAPES_PACK_ID)
sticker("triangle", R.drawable.sticker_triangle, "🟢", SHAPES_PACK_ID)
sticker("diamond", R.drawable.sticker_diamond, "💎", SHAPES_PACK_ID)
sticker("moon", R.drawable.sticker_moon, "🌙", SHAPES_PACK_ID)
sticker("sun", R.drawable.sticker_sun, "☀️", SHAPES_PACK_ID)
sticker("bolt", R.drawable.sticker_bolt, "⚡", SHAPES_PACK_ID)
sticker("happy", R.drawable.sticker_face_happy, "😀", FACES_PACK_ID)
sticker("sad", R.drawable.sticker_face_sad, "😢", FACES_PACK_ID)
sticker("wink", R.drawable.sticker_face_wink, "😉", FACES_PACK_ID)
sticker("surprised", R.drawable.sticker_face_surprised, "😲", FACES_PACK_ID)
sticker("cool", R.drawable.sticker_face_cool, "😎", FACES_PACK_ID)
sticker("angry", R.drawable.sticker_face_angry, "😡", FACES_PACK_ID)
}
private val rasterized = mutableMapOf<Int, Bitmap>()
private fun rasterize(@DrawableRes resource: Int): Bitmap {
return rasterized.getOrPut(resource) {
val drawable = requireNotNull(context.getDrawable(resource))
val bitmap = Bitmap.createBitmap(STICKER_SIZE, STICKER_SIZE, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bitmap
}
}
private val stickersByName: Map<String, KeyboardSticker> = demoStickers.associate { it.name to it.toKeyboardSticker() }
private val recents = ArrayDeque<KeyboardSticker>()
private val packsFlow = MutableStateFlow(buildPacks())
override fun observeStickerPacks(): Flow<List<KeyboardStickerPack>> = packsFlow
override fun onStickerUsed(sticker: KeyboardSticker) {
recents.remove(sticker)
recents.addFirst(sticker)
while (recents.size > MAX_RECENTS) {
recents.removeLast()
}
packsFlow.value = buildPacks()
}
private fun buildPacks(): List<KeyboardStickerPack> {
return buildList {
if (recents.isNotEmpty()) {
add(
KeyboardStickerPack(
id = StickerKeyboardRepository.RECENT_PACK_ID,
title = null,
cover = null,
stickers = recents.toList()
)
)
}
add(pack(SHAPES_PACK_ID, "Shapes", R.drawable.sticker_star))
add(pack(FACES_PACK_ID, "Faces", R.drawable.sticker_face_happy))
}
}
private fun pack(id: String, title: String, @DrawableRes cover: Int): KeyboardStickerPack {
return KeyboardStickerPack(
id = id,
title = title,
cover = rasterize(cover),
stickers = demoStickers.filter { it.packId == id }.map { stickersByName.getValue(it.name) }
)
}
private fun DemoSticker.toKeyboardSticker(): KeyboardSticker {
return KeyboardSticker(
packId = packId,
packKey = "$packId-key",
stickerId = id,
emoji = emoji,
image = rasterize(image)
)
}
companion object {
private const val SHAPES_PACK_ID = "shapes"
private const val FACES_PACK_ID = "faces"
private const val MAX_RECENTS = 12
private const val STICKER_SIZE = 512
}
}
@@ -0,0 +1,217 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
import org.signal.core.ui.compose.keyboard.KeyboardSheetScaffold
import org.signal.core.ui.compose.keyboard.rememberKeyboardSheetController
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
import org.signal.mediakeyboard.MediaKeyboard
import org.signal.mediakeyboard.MediaKeyboardAction
import org.signal.mediakeyboard.data.MediaKeyboardRepository
private val MEDIA_KEYBOARD = KeyboardSheetKey("demo.media")
@Composable
fun MainScreen(
state: MainScreenState,
onEvent: (MainScreenEvents) -> Unit,
repository: MediaKeyboardRepository,
modifier: Modifier = Modifier
) {
val controller = rememberKeyboardSheetController()
val onKeyboardAction: (MediaKeyboardAction) -> Unit = remember(onEvent) {
{ action ->
when (action) {
is MediaKeyboardAction.EmojiSelected -> onEvent(MainScreenEvents.EmojiSelected(action.emoji))
MediaKeyboardAction.Backspace -> onEvent(MainScreenEvents.BackspacePressed)
is MediaKeyboardAction.StickerSelected -> onEvent(MainScreenEvents.StickerSelected(action.sticker))
is MediaKeyboardAction.GifSelected -> onEvent(MainScreenEvents.GifSelected(action.gif))
// Nothing to remember the tab for, and no sticker packs to manage, view, or search.
is MediaKeyboardAction.TabSelected,
MediaKeyboardAction.StickerManagementClicked,
MediaKeyboardAction.StickerSearchClicked,
MediaKeyboardAction.GifSearchClicked,
is MediaKeyboardAction.ViewStickerPackClicked -> Unit
}
}
}
LaunchedEffect(state.keyboardVisible) {
if (state.keyboardVisible) controller.show(MEDIA_KEYBOARD) else controller.hide()
}
KeyboardSheetScaffold(
controller = controller,
onAction = { action ->
if (action is KeyboardSheetAction.KeyboardHidden) {
onEvent(MainScreenEvents.MediaKeyboardDismissed)
}
},
keyboardsProvider = {
keyboard(key = MEDIA_KEYBOARD, expandable = true) {
MediaKeyboard(
repository = repository,
onAction = onKeyboardAction
)
}
},
modifier = modifier
) {
Column(modifier = Modifier.fillMaxSize()) {
MessageList(
messages = state.messages,
modifier = Modifier.weight(1f)
)
ComposerBar(state = state, onEvent = onEvent)
}
}
}
@Composable
private fun MessageList(
messages: List<DemoMessage>,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LazyColumn(
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxWidth()
) {
items(messages, key = { it.id }) { message ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
when (message) {
is DemoMessage.Text -> TextMessage(message)
is DemoMessage.Sticker -> StickerMessage(message)
is DemoMessage.Gif -> GifMessage(message)
}
}
}
}
}
@Composable
private fun TextMessage(message: DemoMessage.Text) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.large
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
@Composable
private fun StickerMessage(message: DemoMessage.Sticker) {
GlideImage(
model = message.sticker.image,
modifier = Modifier.size(120.dp)
)
}
@Composable
private fun GifMessage(message: DemoMessage.Gif) {
GlideImage(
model = message.gif.still,
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier
.width(220.dp)
.aspectRatio(message.gif.aspectRatio)
.clip(MaterialTheme.shapes.medium)
)
}
@Composable
private fun ComposerBar(
state: MainScreenState,
onEvent: (MainScreenEvents) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
IconButton(onClick = { onEvent(MainScreenEvents.OpenMediaKeyboard) }) {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = "Open media keyboard",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextField(
value = state.composerText,
onValueChange = { onEvent(MainScreenEvents.ComposerTextChanged(it)) },
placeholder = { Text(text = "Message") },
singleLine = true,
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onEvent(MainScreenEvents.SendClicked) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.main
import org.signal.mediakeyboard.data.KeyboardGif
import org.signal.mediakeyboard.data.KeyboardSticker
sealed class MainScreenEvents {
data object OpenMediaKeyboard : MainScreenEvents()
data object MediaKeyboardDismissed : MainScreenEvents()
data class ComposerTextChanged(val text: String) : MainScreenEvents()
data object SendClicked : MainScreenEvents()
data class EmojiSelected(val emoji: String) : MainScreenEvents()
data object BackspacePressed : MainScreenEvents()
data class StickerSelected(val sticker: KeyboardSticker) : MainScreenEvents()
data class GifSelected(val gif: KeyboardGif) : MainScreenEvents()
}
@@ -0,0 +1,28 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.main
import org.signal.mediakeyboard.data.KeyboardGif
import org.signal.mediakeyboard.data.KeyboardSticker
import java.util.concurrent.atomic.AtomicLong
data class MainScreenState(
val messages: List<DemoMessage> = emptyList(),
val composerText: String = "",
val keyboardVisible: Boolean = false
)
sealed class DemoMessage {
val id: Long = NEXT_ID.getAndIncrement()
data class Text(val text: String) : DemoMessage()
data class Sticker(val sticker: KeyboardSticker) : DemoMessage()
data class Gif(val gif: KeyboardGif) : DemoMessage()
private companion object {
val NEXT_ID = AtomicLong()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.demo.main
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.logging.Log
class MainScreenViewModel : EventDrivenViewModel<MainScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(MainScreenViewModel::class)
}
private val _state = MutableStateFlow(MainScreenState())
val state: StateFlow<MainScreenState> = _state.asStateFlow()
override suspend fun processEvent(event: MainScreenEvents) {
val state = _state.value
_state.value = when (event) {
MainScreenEvents.OpenMediaKeyboard -> state.copy(keyboardVisible = true)
MainScreenEvents.MediaKeyboardDismissed -> state.copy(keyboardVisible = false)
is MainScreenEvents.ComposerTextChanged -> state.copy(composerText = event.text)
MainScreenEvents.SendClicked -> {
if (state.composerText.isBlank()) {
state
} else {
state.copy(
messages = state.messages + DemoMessage.Text(state.composerText.trim()),
composerText = ""
)
}
}
is MainScreenEvents.EmojiSelected -> state.copy(composerText = state.composerText + event.emoji)
MainScreenEvents.BackspacePressed -> state.copy(composerText = state.composerText.dropLastGrapheme())
is MainScreenEvents.StickerSelected -> state.copy(messages = state.messages + DemoMessage.Sticker(event.sticker))
is MainScreenEvents.GifSelected -> {
state.copy(
messages = state.messages + DemoMessage.Gif(event.gif),
keyboardVisible = false
)
}
}
}
private fun String.dropLastGrapheme(): String {
if (isEmpty()) {
return this
}
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(this)
return iterator.take(iterator.countBreaks() - 1).toString()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,35 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" android:fillColor="#FBC02D"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#2196F3"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2L22,12L12,22L2,12Z" android:fillColor="#9C27B0"/>
</vector>
@@ -0,0 +1,15 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M6.5,7l3,1.5 M17.5,7l-3,1.5" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
<path android:pathData="M7.5,16c1.2,-2.4 7.8,-2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,13 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M5,8.5h6v3.5h-6z M13,8.5h6v3.5h-6z M11,9.5h2v1h-2z" android:fillColor="#37474F"/>
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,14 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,14 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M7.5,16c1.2,-2.4 7.8,-2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,14 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M12,15.5m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#4E342E"/>
</vector>
@@ -0,0 +1,14 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
<path android:pathData="M14,9.5h3" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" android:fillColor="#E91E63"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,3a9,9 0 1,0 9,9c0,-0.46 -0.04,-0.92 -0.1,-1.36a5.389,5.389 0 0,1 -4.4,2.26 5.403,5.403 0 0,1 -3.14,-9.8c-0.44,-0.06 -0.9,-0.1 -1.36,-0.1z" android:fillColor="#607D8B"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" android:fillColor="#FFC107"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M6.76,4.84l-1.8,-1.79 -1.41,1.41 1.79,1.79 1.42,-1.41zM4,10.5L1,10.5v2h3v-2zM13,0.55h-2L11,3.5h2L13,0.55zM20.45,4.46l-1.41,-1.41 -1.79,1.79 1.41,1.41 1.79,-1.79zM17.24,18.16l1.79,1.8 1.41,-1.41 -1.8,-1.79 -1.4,1.4zM20,10.5v2h3v-2h-3zM12,5.5c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM11,22.45h2L13,19.5h-2v2.95zM3.55,18.54l1.41,1.41 1.79,-1.8 -1.41,-1.41 -1.79,1.8z" android:fillColor="#FF9800"/>
</vector>
@@ -0,0 +1,11 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M12,3L22,21L2,21Z" android:fillColor="#4CAF50"/>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<string name="app_name">MediaKeyboardDemo</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<style name="Theme.MediaKeyboardDemo" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+69
View File
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("signal-library")
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.signal.mediakeyboard"
buildFeatures {
compose = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
lintChecks(project(":lintchecks"))
// Signal Core
implementation(project(":core:util"))
implementation(project(":core:ui"))
implementation(project(":lib:glide"))
// Compose BOM
platform(libs.androidx.compose.bom).let { composeBom ->
implementation(composeBom)
androidTestImplementation(composeBom)
}
// Compose dependencies
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling.core)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Core AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Media3 for gif (mp4) playback
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui.compose)
// Rendering custom emoji drawables
implementation(libs.accompanist.drawablepainter)
// Testing
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)
testImplementation(testLibs.assertk)
testImplementation(testLibs.kotlinx.coroutines.test)
testImplementation(testLibs.robolectric.robolectric)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,410 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.ui.compose.keyboard.LocalKeyboardSheetController
import org.signal.mediakeyboard.data.MediaKeyboardRepository
import org.signal.mediakeyboard.screens.LocalMediaKeyboardExpanded
import org.signal.mediakeyboard.screens.emoji.EmojiPageScreen
import org.signal.mediakeyboard.screens.emoji.EmojiPageViewModel
import org.signal.mediakeyboard.screens.gif.GifPageScreen
import org.signal.mediakeyboard.screens.gif.GifPageViewModel
import org.signal.mediakeyboard.screens.sticker.StickerPageScreen
import org.signal.mediakeyboard.screens.sticker.StickerPageViewModel
/**
* The emoji/sticker/gif media keyboard.
*
* Fills whatever space its host hands it, so the host owns the surface, its window insets, and any
* drag handle. Search needs more room than a keyboard-sized space and a system keyboard to type the
* query into, so it takes text entry over from the scaffold via
* [org.signal.core.ui.compose.keyboard.KeyboardSheetController]; the scaffold is then the one
* deciding the height and the system keyboard together.
*
* Layout: a top bar with search, text tabs, and a per-tab action, then the page content, with each
* page providing its own bottom rail (emoji categories, sticker packs, gif quick searches).
*
* @param repository Supplies the emoji, sticker, and gif data.
* @param onAction Receives what the user picks, and anything only a host can carry out.
* @param tabs The only tabs to offer, or null to offer everything [repository] has. Hosts that
* cannot accept every kind of media, such as a conversation editing a message, narrow this.
* @param initialTab The tab to open on. A host that recorded [MediaKeyboardAction.TabSelected]
* passes back what it recorded; null opens on the first tab offered.
*/
@Composable
fun MediaKeyboard(
repository: MediaKeyboardRepository,
onAction: (MediaKeyboardAction) -> Unit,
modifier: Modifier = Modifier,
tabs: Set<MediaKeyboardTab>? = null,
initialTab: MediaKeyboardTab? = null
) {
val host = LocalKeyboardSheetController.current
val viewModel: MediaKeyboardViewModel = viewModel(factory = MediaKeyboardViewModel.Factory(repository, host.isEnteringText, initialTab))
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(tabs) {
viewModel.restrictTabs(tabs)
}
val searching = state.searchActive
LaunchedEffect(searching) {
if (searching) {
viewModel.onEvent(MediaKeyboardScreenEvents.SearchQueryChanged(""))
}
}
BackHandler(enabled = searching) {
host.endTextEntry()
}
// Two search affordances at once is one too many, so the icon goes while a page's own field shows.
var searchFieldRevealed by remember { mutableStateOf(false) }
// Reset on a tab change so a page without a field of its own never inherits the last page's answer.
LaunchedEffect(state.selectedTab) {
searchFieldRevealed = false
}
CompositionLocalProvider(LocalMediaKeyboardExpanded provides host.isExpanded) {
Column(
modifier = modifier
.fillMaxSize()
// The system keyboard the search field summons sits on top of us rather than beside us.
.then(if (searching) Modifier.imePadding() else Modifier)
) {
if (searching) {
MediaKeyboardSearchBar(state = state, onEvent = viewModel::onEvent, onCloseSearch = host::endTextEntry)
} else {
MediaKeyboardTopBar(
state = state,
onEvent = viewModel::onEvent,
onAction = onAction,
onOpenSearch = host::beginTextEntry,
searchFieldRevealed = searchFieldRevealed
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (state.initialized) {
when (state.selectedTab) {
MediaKeyboardTab.EMOJI -> EmojiTab(repository, viewModel.state, onAction)
MediaKeyboardTab.STICKER -> StickerTab(
repository = repository,
onAction = onAction,
onSearchFieldRevealedChange = { searchFieldRevealed = it }
)
MediaKeyboardTab.GIF -> GifTab(
repository = repository,
onAction = onAction,
onSearchFieldRevealedChange = { searchFieldRevealed = it }
)
}
}
}
}
}
}
@Composable
private fun MediaKeyboardTopBar(
state: MediaKeyboardState,
onEvent: (MediaKeyboardScreenEvents) -> Unit,
onAction: (MediaKeyboardAction) -> Unit,
onOpenSearch: () -> Unit,
searchFieldRevealed: Boolean
) {
// A null action means searching in place, which emoji is now the only page to do.
val searchAction = when (state.selectedTab) {
MediaKeyboardTab.EMOJI -> null
MediaKeyboardTab.STICKER -> MediaKeyboardAction.StickerSearchClicked
MediaKeyboardTab.GIF -> MediaKeyboardAction.GifSearchClicked
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
) {
// Kept in the layout so it fades in place and the tabs never shift.
val searchAlpha by animateFloatAsState(
targetValue = if (searchFieldRevealed) 0f else 1f,
label = "searchAlpha"
)
IconButton(
onClick = {
if (searchAction != null) {
onAction(searchAction)
} else {
onOpenSearch()
}
},
enabled = searchAlpha > 0f,
modifier = Modifier.graphicsLayer { alpha = searchAlpha }
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = stringResource(R.string.MediaKeyboard__search),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f)
) {
if (state.availableTabs.size > 1) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.availableTabs.forEach { tab ->
TabPill(
tab = tab,
selected = tab == state.selectedTab,
onClick = {
onEvent(MediaKeyboardScreenEvents.TabSelected(tab))
onAction(MediaKeyboardAction.TabSelected(tab))
}
)
}
}
}
}
when (state.selectedTab) {
MediaKeyboardTab.EMOJI -> {
IconButton(onClick = { onAction(MediaKeyboardAction.Backspace) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Backspace,
contentDescription = stringResource(R.string.MediaKeyboard__backspace),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MediaKeyboardTab.STICKER -> {
IconButton(onClick = { onAction(MediaKeyboardAction.StickerManagementClicked) }) {
Icon(
imageVector = Icons.Outlined.AddCircleOutline,
contentDescription = stringResource(R.string.MediaKeyboard__manage_stickers),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MediaKeyboardTab.GIF -> {
Box(modifier = Modifier.size(48.dp))
}
}
}
}
@Composable
private fun TabPill(
tab: MediaKeyboardTab,
selected: Boolean,
onClick: () -> Unit
) {
val background = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
val textColor = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
Text(
text = stringResource(tab.label()),
style = MaterialTheme.typography.labelLarge,
color = textColor,
modifier = Modifier
.clip(CircleShape)
.background(background)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp)
)
}
@Composable
private fun EmojiTab(
repository: MediaKeyboardRepository,
parentStateFlow: StateFlow<MediaKeyboardState>,
onAction: (MediaKeyboardAction) -> Unit
) {
val viewModel: EmojiPageViewModel = viewModel(
key = "media-keyboard-emoji",
factory = EmojiPageViewModel.Factory(repository.emoji, parentStateFlow, onAction)
)
val state by viewModel.state.collectAsStateWithLifecycle()
EmojiPageScreen(
state = state,
onEvent = viewModel::onEvent,
getEmojiDrawable = repository.emoji::getEmojiDrawable
)
}
@Composable
private fun StickerTab(
repository: MediaKeyboardRepository,
onAction: (MediaKeyboardAction) -> Unit,
onSearchFieldRevealedChange: (Boolean) -> Unit
) {
val viewModel: StickerPageViewModel = viewModel(
key = "media-keyboard-sticker",
factory = StickerPageViewModel.Factory(repository.stickers, onAction)
)
val state by viewModel.state.collectAsStateWithLifecycle()
StickerPageScreen(
state = state,
onEvent = viewModel::onEvent,
onSearchFieldRevealedChange = onSearchFieldRevealedChange
)
}
@Composable
private fun GifTab(
repository: MediaKeyboardRepository,
onAction: (MediaKeyboardAction) -> Unit,
onSearchFieldRevealedChange: (Boolean) -> Unit
) {
val viewModel: GifPageViewModel = viewModel(
key = "media-keyboard-gif",
factory = GifPageViewModel.Factory(repository.gifs, onAction)
)
val state by viewModel.state.collectAsStateWithLifecycle()
GifPageScreen(
state = state,
onEvent = viewModel::onEvent,
onSearchFieldRevealedChange = onSearchFieldRevealedChange
)
}
@Composable
private fun MediaKeyboardSearchBar(
state: MediaKeyboardState,
onEvent: (MediaKeyboardScreenEvents) -> Unit,
onCloseSearch: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
IconButton(onClick = onCloseSearch) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.MediaKeyboard__close_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextField(
value = state.searchQuery,
onValueChange = { onEvent(MediaKeyboardScreenEvents.SearchQueryChanged(it)) },
placeholder = { Text(text = stringResource(state.selectedTab.searchHint())) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingIcon = {
if (state.searchQuery.isNotEmpty()) {
IconButton(onClick = { onEvent(MediaKeyboardScreenEvents.SearchQueryChanged("")) }) {
Icon(
imageVector = Icons.Outlined.Clear,
contentDescription = stringResource(R.string.MediaKeyboard__clear_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
singleLine = true,
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@StringRes
private fun MediaKeyboardTab.label(): Int {
return when (this) {
MediaKeyboardTab.EMOJI -> R.string.MediaKeyboard__emoji
MediaKeyboardTab.STICKER -> R.string.MediaKeyboard__stickers
MediaKeyboardTab.GIF -> R.string.MediaKeyboard__gifs
}
}
@StringRes
private fun MediaKeyboardTab.searchHint(): Int {
return when (this) {
MediaKeyboardTab.EMOJI -> R.string.MediaKeyboard__search_emoji
MediaKeyboardTab.STICKER -> R.string.MediaKeyboard__search_stickers
MediaKeyboardTab.GIF -> R.string.MediaKeyboard__search_gifs
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
import org.signal.mediakeyboard.data.KeyboardGif
import org.signal.mediakeyboard.data.KeyboardSticker
/**
* Side effects emitted by a [MediaKeyboard] that need to be handled by the user of the component:
* what the user picked, and the things only a host can carry out.
*
* Requests flow the other way as [MediaKeyboardScreenEvents], and everything about the space the
* keyboard is given is the scaffold's, as
* [org.signal.core.ui.compose.keyboard.KeyboardSheetAction].
*/
sealed interface MediaKeyboardAction {
/** @param emoji The emoji to insert, already resolved to the user's preferred variation. */
data class EmojiSelected(val emoji: String) : MediaKeyboardAction
/** Delete backwards from wherever the host's own field has its cursor. */
data object Backspace : MediaKeyboardAction
/** @param sticker The sticker to send. */
data class StickerSelected(val sticker: KeyboardSticker) : MediaKeyboardAction
/** @param gif The gif to send. */
data class GifSelected(val gif: KeyboardGif) : MediaKeyboardAction
/**
* The user switched tabs. The keyboard has no memory of its own across the times it is shown, so
* a host that wants to reopen on the tab last picked has to record this.
*
* @param tab The tab now showing.
*/
data class TabSelected(val tab: MediaKeyboardTab) : MediaKeyboardAction
/** Take the user to wherever sticker packs are managed. */
data object StickerManagementClicked : MediaKeyboardAction
/**
* Take the user to sticker search, which is a screen of its own rather than a field in the
* keyboard. Emitted by both the sticker page's search field and the top bar's search icon.
*/
data object StickerSearchClicked : MediaKeyboardAction
/** Take the user to gif search. As [StickerSearchClicked], but for the gif page. */
data object GifSearchClicked : MediaKeyboardAction
/**
* Show the user everything in a sticker pack.
*
* @param packId The pack to open.
* @param packKey Its key, which identifies the pack alongside [packId].
*/
data class ViewStickerPackClicked(val packId: String, val packKey: String) : MediaKeyboardAction
}
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
/**
* User interactions with a [MediaKeyboard], applied by [MediaKeyboardViewModel].
*/
sealed interface MediaKeyboardScreenEvents {
data object Initialize : MediaKeyboardScreenEvents
data class TabSelected(val tab: MediaKeyboardTab) : MediaKeyboardScreenEvents
data class SearchQueryChanged(val query: String) : MediaKeyboardScreenEvents
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
/**
* @param offeredTabs Everything the repository has to offer, before any restriction.
* @param restrictedTo Narrows the keyboard on the host's behalf, or null for no restriction.
* @param preferredTab The tab the user last picked, returned to whenever it is reachable again.
* Seeded by the host from what it recorded of [MediaKeyboardAction.TabSelected], since the
* keyboard has no memory of its own across the times it is shown.
* @param searchActive Whether the search surface is up. Owned by the scaffold rather than tracked
* here, and not the events' to set; see
* [org.signal.core.ui.compose.keyboard.KeyboardSheetController.isEnteringText].
* @param searchQuery What the user has typed, which is this keyboard's own to keep. Only meaningful
* while [searchActive]; left behind rather than cleared when search closes.
*/
data class MediaKeyboardState(
val offeredTabs: List<MediaKeyboardTab> = emptyList(),
val restrictedTo: Set<MediaKeyboardTab>? = null,
val preferredTab: MediaKeyboardTab = MediaKeyboardTab.EMOJI,
val searchActive: Boolean = false,
val searchQuery: String = "",
val initialized: Boolean = false
) {
/** What the user can actually reach. A single tab hides the tab row altogether. */
val availableTabs: List<MediaKeyboardTab> = offeredTabs.filter { restrictedTo == null || it in restrictedTo }
val selectedTab: MediaKeyboardTab = availableTabs.firstOrNull { it == preferredTab }
?: availableTabs.firstOrNull()
?: MediaKeyboardTab.EMOJI
}
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard
/**
* The top-level content types available in the media keyboard.
*/
enum class MediaKeyboardTab {
EMOJI,
STICKER,
GIF
}
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.data
import android.graphics.drawable.Drawable
import androidx.annotation.StringRes
import org.signal.mediakeyboard.R
/**
* Data source for the emoji page of the media keyboard.
*/
interface EmojiKeyboardRepository {
/**
* All emoji, grouped into display categories. Should not include [EmojiKeyboardCategory.RECENTS],
* which is fetched separately via [getRecentEmoji].
*/
suspend fun getEmojiPages(): List<EmojiCategoryPage>
/**
* Recently-used emoji, most recent first. May be empty.
*/
suspend fun getRecentEmoji(): List<KeyboardEmoji>
/**
* Emoji matching the given search query.
*/
suspend fun search(query: String): List<KeyboardEmoji>
/**
* The user's preferred (e.g. skin tone) variation for each canonical emoji.
*/
suspend fun getPreferredVariations(): Map<String, String>
fun setPreferredVariation(canonical: String, variation: String)
/**
* Called whenever the user picks an emoji, so that recents can be tracked.
*/
suspend fun onEmojiUsed(emoji: String)
/**
* A custom drawable to render the given emoji with, or null to render it as plain text with
* the system emoji font.
*/
fun getEmojiDrawable(emoji: String): Drawable? = null
}
/**
* A single selectable emoji.
*
* @param canonical The default form of the emoji.
* @param variations All selectable variations (e.g. skin tones), including the canonical form.
* Empty if the emoji has no variations.
*/
data class KeyboardEmoji(
val canonical: String,
val variations: List<String> = emptyList()
) {
val hasVariations: Boolean = variations.size > 1
}
data class EmojiCategoryPage(
val category: EmojiKeyboardCategory,
val emoji: List<KeyboardEmoji>
)
enum class EmojiKeyboardCategory(val key: String, @field:StringRes val label: Int) {
RECENTS("Recents", R.string.MediaKeyboard__recently_used),
PEOPLE("People", R.string.MediaKeyboard__smileys_and_people),
NATURE("Nature", R.string.MediaKeyboard__nature),
FOODS("Foods", R.string.MediaKeyboard__food),
ACTIVITY("Activity", R.string.MediaKeyboard__activities),
PLACES("Places", R.string.MediaKeyboard__places),
OBJECTS("Objects", R.string.MediaKeyboard__objects),
SYMBOLS("Symbols", R.string.MediaKeyboard__symbols),
FLAGS("Flags", R.string.MediaKeyboard__flags),
EMOTICONS("Emoticons", R.string.MediaKeyboard__emoticons)
}
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.data
import android.net.Uri
import org.signal.core.util.Result
/**
* Data source for the gif page of the media keyboard.
*/
interface GifKeyboardRepository {
/**
* Fetches a page of gifs. An empty [query] means trending.
*/
suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError>
}
data class GifPage(
val gifs: List<KeyboardGif>,
val hasMore: Boolean
)
sealed interface GifFetchError {
data object Network : GifFetchError
data class Unknown(val cause: Throwable? = null) : GifFetchError
}
/**
* A single selectable gif.
*
* @param still A Glide-loadable model for a static preview image (e.g. a url or resource id).
* @param mp4PreviewUri A looping mp4 rendition to autoplay in the grid, if available.
*/
data class KeyboardGif(
val id: String,
val still: Any?,
val mp4PreviewUri: Uri?,
val width: Int,
val height: Int
) {
val aspectRatio: Float = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else 1f
}
@@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.data
import org.signal.mediakeyboard.MediaKeyboardTab
/**
* The full set of data sources the media keyboard needs to operate. The main app provides
* implementations backed by its real data stores, while demo apps can provide fakes.
*/
interface MediaKeyboardRepository {
val emoji: EmojiKeyboardRepository
val stickers: StickerKeyboardRepository
val gifs: GifKeyboardRepository
/**
* Which tabs should be shown. e.g. the gif tab may be disabled via remote config, or the
* sticker tab hidden if the user has no packs.
*/
suspend fun getAvailableTabs(): Set<MediaKeyboardTab> = MediaKeyboardTab.entries.toSet()
}
@@ -0,0 +1,63 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.data
import kotlinx.coroutines.flow.Flow
/**
* Data source for the sticker page of the media keyboard.
*/
interface StickerKeyboardRepository {
/**
* Whether animated (e.g. APNG) stickers should animate on this device.
*/
val allowStickerAnimation: Boolean
get() = true
/**
* The user's installed sticker packs, re-emitted whenever the underlying data changes.
* If the user has recently-used stickers, the first pack should be a synthetic pack with
* id [RECENT_PACK_ID].
*/
fun observeStickerPacks(): Flow<List<KeyboardStickerPack>>
/**
* Called whenever the user picks a sticker, so that recents can be tracked.
*/
fun onStickerUsed(sticker: KeyboardSticker) = Unit
companion object {
const val RECENT_PACK_ID = "media-keyboard-recents"
}
}
/**
* A single selectable sticker.
*
* @param packKey The key of the pack this came from, which identifies the pack alongside [packId]
* wherever it is opened. Carried on the sticker rather than the pack because recents mixes packs.
* @param image A Glide-loadable model for the sticker image (e.g. a Uri or resource id).
* @param isAnimated Whether the image is an animated (APNG) sticker.
*/
data class KeyboardSticker(
val packId: String,
val packKey: String,
val stickerId: Long,
val emoji: String?,
val image: Any,
val isAnimated: Boolean = false
)
/**
* @param cover A Glide-loadable model for the pack's cover image.
*/
data class KeyboardStickerPack(
val id: String,
val title: String?,
val cover: Any?,
val stickers: List<KeyboardSticker>
)
@@ -0,0 +1,145 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.SignalPreviewWrapper
import org.signal.mediakeyboard.R
/**
* The content padding of a page's grid.
*
* Shared with the pages rather than left to each of them because opening scrolled past
* [MediaKeyboardSearchField] means scrolling the leading padding away too, and a page that measured
* a different value than it laid out with would leave the field peeking.
*/
internal val GRID_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
/**
* Looks like a field but is not one: tapping it hands search off to the host, which has a whole
* screen to give it rather than the strip of keyboard available here.
*
* Pages put this first in their scrolling content and open scrolled past it, so it is something the
* user pulls down to rather than something holding a row of results' worth of room.
*
* @param hint What is being searched, shown in place of anything typed.
*/
@Composable
internal fun MediaKeyboardSearchField(
hint: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(50))
.clip(RoundedCornerShape(50))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
imageVector = SignalIcons.Search.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Text(
text = hint,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@PreviewWrapper(SignalPreviewWrapper::class)
@DayNightPreviews
@Composable
private fun MediaKeyboardSearchFieldPreview() {
MediaKeyboardSearchField(
hint = stringResource(R.string.MediaKeyboard__search_stickers),
onClick = {}
)
}
/**
* Opens a page's grid scrolled past its [MediaKeyboardSearchField] and reports when the user has
* pulled back down to it, so the top bar can drop its now-redundant search icon.
*
* Effects only. The two pages hold different kinds of grid state, which share no supertype, so the
* grid is reached through lambdas rather than passed in.
*
* @param hasContent Whether the grid has anything to scroll yet; until it does there is nothing to
* hide the field behind.
* @param firstVisibleItemIndex The grid's first visible item. Read inside a [derivedStateOf] so
* that scrolling does not recompose the page.
* @param scrollPastField Scrolls the grid to an item and offset.
* @param onRevealedChange Receives whether the field is showing.
* @param resetKey Hides the field again whenever this changes, for a grid whose contents are
* replaced and whose scroll goes back to the top with them.
*/
@Composable
internal fun SearchFieldReveal(
hasContent: Boolean,
firstVisibleItemIndex: () -> Int,
scrollPastField: suspend (index: Int, offset: Int) -> Unit,
onRevealedChange: (Boolean) -> Unit,
resetKey: Any? = Unit
) {
// Scrolling to an item stops at the leading content padding rather than the top of the viewport,
// and that gap is just enough to leave the bottom of the field showing, so it goes too.
val topContentPaddingPx = with(LocalDensity.current) { GRID_CONTENT_PADDING.calculateTopPadding().roundToPx() }
var scrolledPast by remember(resetKey) { mutableStateOf(false) }
LaunchedEffect(resetKey, hasContent) {
if (hasContent && !scrolledPast) {
scrollPastField(1, topContentPaddingPx)
scrolledPast = true
}
}
val currentFirstVisibleItemIndex by rememberUpdatedState(firstVisibleItemIndex)
val revealed by remember { derivedStateOf { currentFirstVisibleItemIndex() == 0 } }
// Held back until the grid has settled where it opens, or the field would be reported as showing
// for the frames before that first scroll and the top bar's icon would blink out and back.
LaunchedEffect(revealed, scrolledPast) {
onRevealedChange(scrolledPast && revealed)
}
}
@@ -0,0 +1,419 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.emoji
import android.graphics.drawable.Drawable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.EmojiFlags
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
import androidx.compose.material.icons.outlined.EmojiNature
import androidx.compose.material.icons.outlined.EmojiObjects
import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.SentimentSatisfied
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.SignalPreviewWrapper
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediakeyboard.R
import org.signal.mediakeyboard.data.EmojiCategoryPage
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.KeyboardEmoji
import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING
import org.signal.mediakeyboard.screens.PinnedRailLayout
@Composable
fun EmojiPageScreen(
state: EmojiPageState,
onEvent: (EmojiPageScreenEvents) -> Unit,
modifier: Modifier = Modifier,
getEmojiDrawable: (String) -> Drawable? = { null }
) {
val searching = state.searchResults != null
PinnedRailLayout(
rail = if (searching) {
null
} else {
{ EmojiCategoryRail(state = state, onEvent = onEvent) }
},
modifier = modifier
) {
if (searching) {
EmojiSearchResults(
state = state,
onEvent = onEvent,
getEmojiDrawable = getEmojiDrawable
)
} else {
EmojiGrid(
state = state,
onEvent = onEvent,
getEmojiDrawable = getEmojiDrawable
)
}
}
}
@Composable
private fun EmojiCategoryRail(
state: EmojiPageState,
onEvent: (EmojiPageScreenEvents) -> Unit
) {
Surface(color = SignalTheme.colors.colorSurface5) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
items(state.pages, key = { it.category.key }) { page ->
CategoryButton(
category = page.category,
selected = page.category == state.selectedCategory,
onClick = { onEvent(EmojiPageScreenEvents.CategorySelected(page.category)) }
)
}
}
}
}
@Composable
private fun CategoryButton(
category: EmojiKeyboardCategory,
selected: Boolean,
onClick: () -> Unit
) {
IconButton(onClick = onClick) {
Icon(
imageVector = category.icon(),
contentDescription = stringResource(category.label),
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = if (selected) {
Modifier
.background(MaterialTheme.colorScheme.secondaryContainer, CircleShape)
.padding(6.dp)
} else {
Modifier
}
)
}
}
@Composable
private fun EmojiGrid(
state: EmojiPageState,
onEvent: (EmojiPageScreenEvents) -> Unit,
getEmojiDrawable: (String) -> Drawable?,
modifier: Modifier = Modifier
) {
val gridState = rememberLazyGridState()
val headerIndices = remember(state.pages) {
var index = 0
buildMap {
state.pages.forEach { page ->
put(page.category, index)
index += 1 + page.emoji.size
}
}
}
// Start scrolled just past the first section header so more content is visible; scrolling up
// still reveals it.
LaunchedEffect(state.pages.isNotEmpty()) {
if (state.pages.isNotEmpty()) {
gridState.scrollToItem(1)
}
}
LaunchedEffect(state.scrollTarget) {
val target = state.scrollTarget ?: return@LaunchedEffect
headerIndices[target]?.let { gridState.scrollToItem(it) }
onEvent(EmojiPageScreenEvents.ScrollTargetConsumed)
}
val visibleCategory by remember(state.pages, headerIndices) {
derivedStateOf {
val firstVisible = gridState.firstVisibleItemIndex
state.pages
.lastOrNull { page -> (headerIndices[page.category] ?: Int.MAX_VALUE) <= firstVisible }
?.category
}
}
LaunchedEffect(visibleCategory) {
visibleCategory?.let { onEvent(EmojiPageScreenEvents.VisibleCategoryChanged(it)) }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 44.dp),
state = gridState,
contentPadding = GRID_CONTENT_PADDING,
modifier = modifier.fillMaxWidth()
) {
state.pages.forEach { page ->
item(key = "header:${page.category.key}", span = { GridItemSpan(maxLineSpan) }) {
Text(
text = stringResource(page.category.label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 4.dp)
)
}
page.emoji.forEachIndexed { index, emoji ->
val cellKey = "${page.category.key}:$index"
item(key = cellKey) {
EmojiCell(
emoji = emoji,
display = state.displayEmoji(emoji),
cellKey = cellKey,
showVariationSelector = state.variationSelector?.cellKey == cellKey,
onEvent = onEvent,
getEmojiDrawable = getEmojiDrawable,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
}
}
@Composable
private fun EmojiSearchResults(
state: EmojiPageState,
onEvent: (EmojiPageScreenEvents) -> Unit,
getEmojiDrawable: (String) -> Drawable?
) {
val results = state.searchResults ?: return
if (results.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
if (state.searchQuery.isNotBlank()) {
Text(
text = stringResource(R.string.MediaKeyboard__no_results_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 44.dp),
contentPadding = GRID_CONTENT_PADDING,
modifier = Modifier.fillMaxSize()
) {
results.forEachIndexed { index, emoji ->
val cellKey = "search:$index"
item(key = cellKey) {
EmojiCell(
emoji = emoji,
display = state.displayEmoji(emoji),
cellKey = cellKey,
showVariationSelector = state.variationSelector?.cellKey == cellKey,
onEvent = onEvent,
getEmojiDrawable = getEmojiDrawable,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiCell(
emoji: KeyboardEmoji,
display: String,
cellKey: String,
showVariationSelector: Boolean,
onEvent: (EmojiPageScreenEvents) -> Unit,
getEmojiDrawable: (String) -> Drawable?,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(CircleShape)
.combinedClickable(
onClick = { onEvent(EmojiPageScreenEvents.EmojiClicked(emoji)) },
onLongClick = { onEvent(EmojiPageScreenEvents.EmojiLongPressed(cellKey, emoji)) }
)
) {
EmojiImage(emoji = display, getEmojiDrawable = getEmojiDrawable)
if (emoji.hasVariations) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(5.dp)
.size(4.dp)
.background(MaterialTheme.colorScheme.outline, CircleShape)
)
}
if (showVariationSelector) {
VariationSelectorPopup(
emoji = emoji,
onEvent = onEvent,
getEmojiDrawable = getEmojiDrawable
)
}
}
}
@Composable
private fun VariationSelectorPopup(
emoji: KeyboardEmoji,
onEvent: (EmojiPageScreenEvents) -> Unit,
getEmojiDrawable: (String) -> Drawable?
) {
val yOffset = with(LocalDensity.current) { -56.dp.roundToPx() }
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, yOffset),
onDismissRequest = { onEvent(EmojiPageScreenEvents.VariationSelectorDismissed) },
properties = PopupProperties(focusable = true)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 4.dp
) {
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
emoji.variations.forEach { variation ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.clickable { onEvent(EmojiPageScreenEvents.VariationSelected(emoji, variation)) }
) {
EmojiImage(emoji = variation, getEmojiDrawable = getEmojiDrawable)
}
}
}
}
}
}
@Composable
private fun EmojiImage(
emoji: String,
getEmojiDrawable: (String) -> Drawable?,
modifier: Modifier = Modifier
) {
val drawable = remember(emoji) { getEmojiDrawable(emoji) }
if (drawable != null) {
Image(
painter = rememberDrawablePainter(drawable),
contentDescription = emoji,
modifier = modifier.size(26.dp)
)
} else {
Text(
text = emoji,
fontSize = if (emoji.isAsciiEmoticon()) 13.sp else 22.sp,
maxLines = 1,
softWrap = false,
modifier = modifier
)
}
}
private fun String.isAsciiEmoticon(): Boolean = all { it.code < 128 }
private fun EmojiKeyboardCategory.icon(): ImageVector {
return when (this) {
EmojiKeyboardCategory.RECENTS -> Icons.Outlined.Schedule
EmojiKeyboardCategory.PEOPLE -> Icons.Outlined.EmojiEmotions
EmojiKeyboardCategory.NATURE -> Icons.Outlined.EmojiNature
EmojiKeyboardCategory.FOODS -> Icons.Outlined.EmojiFoodBeverage
EmojiKeyboardCategory.ACTIVITY -> Icons.Outlined.EmojiEvents
EmojiKeyboardCategory.PLACES -> Icons.Outlined.EmojiTransportation
EmojiKeyboardCategory.OBJECTS -> Icons.Outlined.EmojiObjects
EmojiKeyboardCategory.SYMBOLS -> Icons.Outlined.EmojiSymbols
EmojiKeyboardCategory.FLAGS -> Icons.Outlined.EmojiFlags
EmojiKeyboardCategory.EMOTICONS -> Icons.Outlined.SentimentSatisfied
}
}
@PreviewWrapper(SignalPreviewWrapper::class)
@DayNightPreviews
@Composable
private fun EmojiPageScreenPreview() {
EmojiPageScreen(
state = EmojiPageState(
pages = listOf(
EmojiCategoryPage(
category = EmojiKeyboardCategory.PEOPLE,
emoji = listOf("😀", "😂", "🥰", "😎", "🤔", "😢", "😡", "🥳").map { KeyboardEmoji(it) } +
KeyboardEmoji("👍", listOf("👍", "👍🏻", "👍🏼", "👍🏽", "👍🏾", "👍🏿"))
),
EmojiCategoryPage(
category = EmojiKeyboardCategory.NATURE,
emoji = listOf("🐶", "🐱", "🦊", "🌸", "🌈").map { KeyboardEmoji(it) }
)
),
selectedCategory = EmojiKeyboardCategory.PEOPLE
),
onEvent = {}
)
}
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.emoji
import org.signal.mediakeyboard.MediaKeyboardState
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.KeyboardEmoji
sealed interface EmojiPageScreenEvents {
data object Initialize : EmojiPageScreenEvents
data class ParentStateChanged(val parentState: MediaKeyboardState) : EmojiPageScreenEvents
data class CategorySelected(val category: EmojiKeyboardCategory) : EmojiPageScreenEvents
data class VisibleCategoryChanged(val category: EmojiKeyboardCategory) : EmojiPageScreenEvents
data object ScrollTargetConsumed : EmojiPageScreenEvents
data class EmojiClicked(val emoji: KeyboardEmoji) : EmojiPageScreenEvents
data class EmojiLongPressed(val cellKey: String, val emoji: KeyboardEmoji) : EmojiPageScreenEvents
data class VariationSelected(val emoji: KeyboardEmoji, val variation: String) : EmojiPageScreenEvents
data object VariationSelectorDismissed : EmojiPageScreenEvents
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.emoji
import org.signal.mediakeyboard.data.EmojiCategoryPage
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
import org.signal.mediakeyboard.data.KeyboardEmoji
data class EmojiPageState(
val pages: List<EmojiCategoryPage> = emptyList(),
val selectedCategory: EmojiKeyboardCategory? = null,
val preferredVariations: Map<String, String> = emptyMap(),
val variationSelector: VariationSelectorTarget? = null,
val scrollTarget: EmojiKeyboardCategory? = null,
val searchResults: List<KeyboardEmoji>? = null,
val searchQuery: String = ""
) {
/**
* Identifies the exact grid cell (via its lazy-grid key) that should display the
* skin tone variation popup.
*/
data class VariationSelectorTarget(
val cellKey: String,
val emoji: KeyboardEmoji
)
fun displayEmoji(emoji: KeyboardEmoji): String {
return preferredVariations[emoji.canonical] ?: emoji.canonical
}
}
@@ -0,0 +1,282 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.gif
import androidx.annotation.OptIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
import org.signal.mediakeyboard.R
import org.signal.mediakeyboard.data.KeyboardGif
import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING
import org.signal.mediakeyboard.screens.MediaKeyboardSearchField
import org.signal.mediakeyboard.screens.PinnedRailLayout
import org.signal.mediakeyboard.screens.SearchFieldReveal
/** The search field ahead of the gifs, which item indices have to be shifted back past. */
private const val SEARCH_FIELD_ITEMS = 1
/**
* @param onSearchFieldRevealedChange Reports whether the grid is scrolled far enough up to show the
* search field, so the top bar can drop its now-redundant search icon.
*/
@Composable
fun GifPageScreen(
state: GifPageState,
onEvent: (GifPageScreenEvents) -> Unit,
modifier: Modifier = Modifier,
onSearchFieldRevealedChange: (Boolean) -> Unit = {}
) {
// Only the grid carries the field, so nothing is revealed while loading, retrying or empty.
val showingGrid = !state.isLoading && !state.loadFailed && state.gifs.isNotEmpty()
LaunchedEffect(showingGrid) {
if (!showingGrid) {
onSearchFieldRevealedChange(false)
}
}
PinnedRailLayout(
rail = { GifQuickSearchRail(state = state, onEvent = onEvent) },
modifier = modifier
) {
Box(modifier = Modifier.fillMaxSize()) {
when {
state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
state.loadFailed -> {
GifLoadError(onEvent = onEvent)
}
state.gifs.isEmpty() -> {
Text(
text = stringResource(R.string.MediaKeyboard__no_results_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
GifGrid(
state = state,
onEvent = onEvent,
onSearchFieldRevealedChange = onSearchFieldRevealedChange
)
}
}
}
}
}
@Composable
private fun GifQuickSearchRail(
state: GifPageState,
onEvent: (GifPageScreenEvents) -> Unit
) {
Surface(color = SignalTheme.colors.colorSurface5) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
items(GifQuickSearchOption.entries, key = { it.name }) { option ->
FilterChip(
selected = option == state.selectedQuickSearch,
onClick = { onEvent(GifPageScreenEvents.QuickSearchSelected(option)) },
label = { Text(text = stringResource(option.label)) },
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
}
}
@Composable
private fun GifLoadError(onEvent: (GifPageScreenEvents) -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Text(
text = stringResource(R.string.MediaKeyboard__couldnt_load_gifs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 24.dp)
)
TextButton(onClick = { onEvent(GifPageScreenEvents.RetryClicked) }) {
Text(text = stringResource(R.string.MediaKeyboard__retry))
}
}
}
@Composable
private fun GifGrid(
state: GifPageState,
onEvent: (GifPageScreenEvents) -> Unit,
onSearchFieldRevealedChange: (Boolean) -> Unit
) {
val gridState = rememberLazyStaggeredGridState()
val playerPool = rememberGifPlayerPool()
// Keyed on the quick search, since switching one empties the grid and takes the scroll back to
// the top with it.
SearchFieldReveal(
hasContent = state.gifs.isNotEmpty(),
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
scrollPastField = gridState::scrollToItem,
onRevealedChange = onSearchFieldRevealedChange,
resetKey = state.selectedQuickSearch
)
val shouldLoadMore by remember(state.gifs.size) {
derivedStateOf {
val lastVisibleGif = (gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) - SEARCH_FIELD_ITEMS
lastVisibleGif >= state.gifs.size - 8
}
}
LaunchedEffect(shouldLoadMore, state.gifs.size) {
if (shouldLoadMore) {
onEvent(GifPageScreenEvents.LoadMoreRequested)
}
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
state = gridState,
verticalItemSpacing = 4.dp,
contentPadding = GRID_CONTENT_PADDING,
modifier = Modifier.fillMaxSize()
) {
item(key = "search", span = StaggeredGridItemSpan.FullLine) {
MediaKeyboardSearchField(
hint = stringResource(R.string.MediaKeyboard__search_gifs),
onClick = { onEvent(GifPageScreenEvents.SearchClicked) }
)
}
items(
count = state.gifs.size,
key = { index -> "${state.gifs[index].id}:$index" }
) { index ->
val gif = state.gifs[index]
GifCell(
gif = gif,
playerPool = playerPool,
onClick = { onEvent(GifPageScreenEvents.GifClicked(gif)) },
modifier = Modifier.padding(horizontal = 4.dp)
)
}
if (state.isLoadingMore) {
item(key = "loading-more", span = StaggeredGridItemSpan.FullLine) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
CircularProgressIndicator()
}
}
}
}
}
@OptIn(UnstableApi::class)
@Composable
private fun GifCell(
gif: KeyboardGif,
playerPool: GifPlayerPool,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(gif.aspectRatio)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
) {
GlideImage(
model = gif.still,
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier.fillMaxSize()
)
val mp4PreviewUri = gif.mp4PreviewUri
if (mp4PreviewUri != null) {
var player by remember(gif.id) { mutableStateOf<Player?>(null) }
DisposableEffect(gif.id) {
player = playerPool.acquire(gif.id, mp4PreviewUri)
onDispose {
playerPool.release(gif.id)
player = null
}
}
player?.let {
val presentationState = rememberPresentationState(it)
PlayerSurface(
player = it,
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
modifier = Modifier.resizeWithContentScale(ContentScale.Crop, presentationState.videoSizeDp)
)
}
}
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.gif
import org.signal.mediakeyboard.data.KeyboardGif
sealed interface GifPageScreenEvents {
data object Initialize : GifPageScreenEvents
data class QuickSearchSelected(val option: GifQuickSearchOption) : GifPageScreenEvents
data object LoadMoreRequested : GifPageScreenEvents
data object RetryClicked : GifPageScreenEvents
data class GifClicked(val gif: KeyboardGif) : GifPageScreenEvents
data object SearchClicked : GifPageScreenEvents
}
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediakeyboard.screens.gif
import org.signal.mediakeyboard.data.KeyboardGif
data class GifPageState(
val selectedQuickSearch: GifQuickSearchOption = GifQuickSearchOption.TRENDING,
val gifs: List<KeyboardGif> = emptyList(),
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
val loadFailed: Boolean = false
)

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