diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b7cdc715b..0b70399214 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt deleted file mode 100644 index f16aa5f111..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt +++ /dev/null @@ -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) } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt deleted file mode 100644 index d45d11e4d5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt +++ /dev/null @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt deleted file mode 100644 index b88472b859..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt +++ /dev/null @@ -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( - 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(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) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt deleted file mode 100644 index efceaa0cf4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt +++ /dev/null @@ -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() - - 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) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt index cb149a4abf..9ea3cb9b17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt @@ -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 = mutableSetOf() - private val keyboardStateListeners: MutableSet = 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 - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt index 041edba9a0..37e0db383c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt @@ -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") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt index c2e984cbeb..27e4c38bdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -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?, + 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index e79b4bcd2b..d190402953 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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? = 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?>(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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index a507bdb06f..b2ace0427c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt index e3106278a2..f8f4cb02ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt @@ -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 { fun getNext(): StickerRecord? { if (!cursor.moveToNext()) { @@ -853,9 +864,31 @@ class StickerTables( ) } + fun asSequence(): Sequence = sequence { + var record = getNext() + while (record != null) { + yield(record) + record = getNext() + } + } + override fun close() { cursor.close() } + + override fun iterator(): Iterator { + return ReaderIterator() + } + + private inner class ReaderIterator : Iterator { + 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 { @@ -882,8 +915,10 @@ class StickerTables( } fun asSequence(): Sequence = sequence { - while (getNext() != null) { - yield(getCurrent()) + var record = getNext() + while (record != null) { + yield(record) + record = getNext() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt index 8d8670a5e2..973c3d501e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchDialogFragment.kt @@ -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()?.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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt index 6edf44d58e..d815574d95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/sticker/StickerSearchRepository.kt @@ -19,7 +19,7 @@ class StickerSearchRepository { @WorkerThread fun search(query: String): List { if (query.isEmpty()) { - return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).readAll() + return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).use { reader -> reader.asSequence().toList() } } val maybeEmojiQuery: List = 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 { - val stickers: MutableList = mutableListOf() - use { reader -> - var record: StickerRecord? = reader.getNext() - while (record != null) { - stickers.add(record) - record = reader.getNext() - } - } - return stickers -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalEmojiKeyboardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalEmojiKeyboardRepository.kt new file mode 100644 index 0000000000..a403e60816 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalEmojiKeyboardRepository.kt @@ -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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalGifKeyboardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalGifKeyboardRepository.kt new file mode 100644 index 0000000000..096e5122a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalGifKeyboardRepository.kt @@ -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(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > IMAGE_CACHE_SIZE + } + + override suspend fun getGifs(query: String, offset: Int, limit: Int): Result { + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalMediaKeyboardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalMediaKeyboardRepository.kt new file mode 100644 index 0000000000..e171f79fa0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalMediaKeyboardRepository.kt @@ -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 { + return setOfNotNull( + MediaKeyboardTab.EMOJI, + MediaKeyboardTab.STICKER, + MediaKeyboardTab.GIF.takeIf { RemoteConfig.gifSearchAvailable } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalStickerKeyboardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalStickerKeyboardRepository.kt new file mode 100644 index 0000000000..08d669b4f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediakeyboard/SignalStickerKeyboardRepository.kt @@ -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> { + 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 { + 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 + ) + } +} diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 4501a4e01e..1811c1b7de 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -289,13 +289,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - - - - = _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 { + error("Not inside a KeyboardSheetScaffold's keyboard content.") +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetKey.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetKey.kt new file mode 100644 index 0000000000..615f885259 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetKey.kt @@ -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) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt new file mode 100644 index 0000000000..fe622c8ef2 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt @@ -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( + 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(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) + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt new file mode 100644 index 0000000000..2ba71db681 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt @@ -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() + + 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 + ) +} diff --git a/core/ui/src/main/res/drawable/symbol_stickerpack_24.xml b/core/ui/src/main/res/drawable/symbol_stickerpack_24.xml new file mode 100644 index 0000000000..74e93939e0 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_stickerpack_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/demo/media-keyboard/build.gradle.kts b/demo/media-keyboard/build.gradle.kts new file mode 100644 index 0000000000..400e3cc0ee --- /dev/null +++ b/demo/media-keyboard/build.gradle.kts @@ -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) +} diff --git a/demo/media-keyboard/proguard-rules.pro b/demo/media-keyboard/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/demo/media-keyboard/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/demo/media-keyboard/src/main/AndroidManifest.xml b/demo/media-keyboard/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2503503c14 --- /dev/null +++ b/demo/media-keyboard/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MainActivity.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MainActivity.kt new file mode 100644 index 0000000000..4d82b3622c --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MainActivity.kt @@ -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 + ) + } + } + } + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt new file mode 100644 index 0000000000..3dd3934f99 --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt @@ -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) + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoEmojiKeyboardRepository.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoEmojiKeyboardRepository.kt new file mode 100644 index 0000000000..1a1d908ff0 --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoEmojiKeyboardRepository.kt @@ -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 = 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> = 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 = pages + .flatMap { it.emoji } + .associateBy { it.canonical } + + private val recents = ArrayDeque(listOf("😂", "❤️", "👍", "😍", "🔥", "🎉", "😭", "🙏")) + private val preferredVariations = mutableMapOf() + + override suspend fun getEmojiPages(): List = pages + + override suspend fun getRecentEmoji(): List { + return recents.map { emojiByCanonical[it] ?: KeyboardEmoji(it) } + } + + override suspend fun search(query: String): List { + 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 = 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 + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoGifKeyboardRepository.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoGifKeyboardRepository.kt new file mode 100644 index 0000000000..a75adc9d4a --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoGifKeyboardRepository.kt @@ -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 { + 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)) + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoMediaKeyboardRepository.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoMediaKeyboardRepository.kt new file mode 100644 index 0000000000..8bd5376525 --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoMediaKeyboardRepository.kt @@ -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) +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoStickerKeyboardRepository.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoStickerKeyboardRepository.kt new file mode 100644 index 0000000000..01c1950bdc --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/data/DemoStickerKeyboardRepository.kt @@ -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 = 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() + + 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 = demoStickers.associate { it.name to it.toKeyboardSticker() } + private val recents = ArrayDeque() + private val packsFlow = MutableStateFlow(buildPacks()) + + override fun observeStickerPacks(): Flow> = 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 { + 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 + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt new file mode 100644 index 0000000000..5fe4489ffb --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt @@ -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, + 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 + ) + } + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenEvents.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenEvents.kt new file mode 100644 index 0000000000..c640409545 --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenEvents.kt @@ -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() +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenState.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenState.kt new file mode 100644 index 0000000000..9f9435d8ad --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenState.kt @@ -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 = 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() + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenViewModel.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenViewModel.kt new file mode 100644 index 0000000000..01cad089d5 --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreenViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(MainScreenViewModel::class) + } + + private val _state = MutableStateFlow(MainScreenState()) + val state: StateFlow = _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() + } +} diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_1.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_1.png new file mode 100644 index 0000000000..6b665eba39 Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_1.png differ diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_2.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_2.png new file mode 100644 index 0000000000..c98e14bb5c Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_2.png differ diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_3.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_3.png new file mode 100644 index 0000000000..86edb99409 Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_3.png differ diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_4.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_4.png new file mode 100644 index 0000000000..44d9f1ae7c Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_4.png differ diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_5.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_5.png new file mode 100644 index 0000000000..43c722049d Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_5.png differ diff --git a/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_6.png b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_6.png new file mode 100644 index 0000000000..8bc20cadd5 Binary files /dev/null and b/demo/media-keyboard/src/main/res/drawable-nodpi/demo_gif_still_6.png differ diff --git a/demo/media-keyboard/src/main/res/drawable/ic_launcher_background.xml b/demo/media-keyboard/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..dc71d770f1 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/ic_launcher_foreground.xml b/demo/media-keyboard/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..47c0b369d4 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_bolt.xml b/demo/media-keyboard/src/main/res/drawable/sticker_bolt.xml new file mode 100644 index 0000000000..69144543dd --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_bolt.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_circle.xml b/demo/media-keyboard/src/main/res/drawable/sticker_circle.xml new file mode 100644 index 0000000000..5432420ccf --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_circle.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_diamond.xml b/demo/media-keyboard/src/main/res/drawable/sticker_diamond.xml new file mode 100644 index 0000000000..601f0f3f00 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_diamond.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_angry.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_angry.xml new file mode 100644 index 0000000000..331b651e39 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_angry.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_cool.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_cool.xml new file mode 100644 index 0000000000..7e28ee4ae6 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_cool.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_happy.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_happy.xml new file mode 100644 index 0000000000..b2569c5b9d --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_happy.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_sad.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_sad.xml new file mode 100644 index 0000000000..eb5cc9bc55 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_sad.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_surprised.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_surprised.xml new file mode 100644 index 0000000000..e367bd4978 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_surprised.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_face_wink.xml b/demo/media-keyboard/src/main/res/drawable/sticker_face_wink.xml new file mode 100644 index 0000000000..d0b17fbae6 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_face_wink.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_heart.xml b/demo/media-keyboard/src/main/res/drawable/sticker_heart.xml new file mode 100644 index 0000000000..c6cbd19fe0 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_heart.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_moon.xml b/demo/media-keyboard/src/main/res/drawable/sticker_moon.xml new file mode 100644 index 0000000000..06b31ce10b --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_moon.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_star.xml b/demo/media-keyboard/src/main/res/drawable/sticker_star.xml new file mode 100644 index 0000000000..7bc3632d84 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_star.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_sun.xml b/demo/media-keyboard/src/main/res/drawable/sticker_sun.xml new file mode 100644 index 0000000000..60d6be5cc2 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_sun.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/drawable/sticker_triangle.xml b/demo/media-keyboard/src/main/res/drawable/sticker_triangle.xml new file mode 100644 index 0000000000..2ecabab769 --- /dev/null +++ b/demo/media-keyboard/src/main/res/drawable/sticker_triangle.xml @@ -0,0 +1,11 @@ + + + + diff --git a/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..81f724fa88 --- /dev/null +++ b/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..81f724fa88 --- /dev/null +++ b/demo/media-keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher.webp b/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher.webp b/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher.webp b/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/demo/media-keyboard/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_1.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_1.mp4 new file mode 100644 index 0000000000..d7ca00e933 Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_1.mp4 differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_2.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_2.mp4 new file mode 100644 index 0000000000..af2cfed228 Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_2.mp4 differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_3.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_3.mp4 new file mode 100644 index 0000000000..54727ee5c9 Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_3.mp4 differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_4.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_4.mp4 new file mode 100644 index 0000000000..a864e4a0ac Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_4.mp4 differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_5.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_5.mp4 new file mode 100644 index 0000000000..e9576df0e6 Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_5.mp4 differ diff --git a/demo/media-keyboard/src/main/res/raw/demo_gif_6.mp4 b/demo/media-keyboard/src/main/res/raw/demo_gif_6.mp4 new file mode 100644 index 0000000000..2ee00134cd Binary files /dev/null and b/demo/media-keyboard/src/main/res/raw/demo_gif_6.mp4 differ diff --git a/demo/media-keyboard/src/main/res/values/strings.xml b/demo/media-keyboard/src/main/res/values/strings.xml new file mode 100644 index 0000000000..96d301522b --- /dev/null +++ b/demo/media-keyboard/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + MediaKeyboardDemo + diff --git a/demo/media-keyboard/src/main/res/values/themes.xml b/demo/media-keyboard/src/main/res/values/themes.xml new file mode 100644 index 0000000000..40d53bba38 --- /dev/null +++ b/demo/media-keyboard/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +