Introduce and integrate new media keyboard.
@@ -711,6 +711,7 @@ dependencies {
|
||||
implementation(project(":feature:app-settings"))
|
||||
implementation(project(":feature:camera"))
|
||||
implementation(project(":feature:chat-settings"))
|
||||
implementation(project(":feature:media-keyboard"))
|
||||
implementation(project(":feature:registration"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose.mediakeyboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Asks a [MediaKeyboardScaffold] for a keyboard.
|
||||
*
|
||||
* Stable and safe to hold outside composition, so view code can call into it. Everything flowing the
|
||||
* other way is a [MediaKeyboardEvents].
|
||||
*
|
||||
* @param initialKeyboardHeightPx Height to use before one has been measured, typically persisted
|
||||
* from an earlier run.
|
||||
*/
|
||||
@Stable
|
||||
class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) {
|
||||
|
||||
/** The keyboard currently up, or null when none of ours is. */
|
||||
var current: MediaKeyboardKey? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Whether the system keyboard is up, from the target of the IME inset animation. */
|
||||
var isSystemKeyboardVisible: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* True while the system keyboard has been asked for in place of one of ours but has yet to settle.
|
||||
* Holds the space across that gap, which the IME service round trip would otherwise leave empty.
|
||||
*/
|
||||
var awaitingSystemKeyboard: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/** How tall a keyboard of ours should be, replaced whenever a system keyboard is measured. */
|
||||
var keyboardHeightPx: Int by mutableStateOf(initialKeyboardHeightPx)
|
||||
internal set
|
||||
|
||||
val isShowing: Boolean get() = current != null
|
||||
|
||||
fun show(key: MediaKeyboardKey) {
|
||||
current = key
|
||||
awaitingSystemKeyboard = false
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
current = null
|
||||
awaitingSystemKeyboard = false
|
||||
}
|
||||
|
||||
/** Closes [key] if it is already up, otherwise swaps to it. */
|
||||
fun toggle(key: MediaKeyboardKey) {
|
||||
if (current == key) hide() else show(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts ours away because the system keyboard is being brought up instead. Only holds the space if
|
||||
* one of ours was up; with nothing to hand over, content should just follow the keyboard in.
|
||||
*/
|
||||
fun hideForSystemKeyboard() {
|
||||
awaitingSystemKeyboard = current != null
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
/** @param initialKeyboardHeightPx See [MediaKeyboardController]. */
|
||||
@Composable
|
||||
fun rememberMediaKeyboardController(initialKeyboardHeightPx: Int = 0): MediaKeyboardController {
|
||||
return remember { MediaKeyboardController(initialKeyboardHeightPx) }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose.mediakeyboard
|
||||
|
||||
/**
|
||||
* Identifies a keyboard a [MediaKeyboardScaffold] can put up. Callers declare their own, so a screen
|
||||
* only knows about the keyboards it offers.
|
||||
*
|
||||
* @param name Unique identifier for the keyboard.
|
||||
*/
|
||||
@JvmInline
|
||||
value class MediaKeyboardKey(val name: String)
|
||||
@@ -1,346 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose.mediakeyboard
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.navigationBarsCompat
|
||||
import org.signal.core.ui.compose.safeDrawingCompat
|
||||
import org.signal.core.ui.compose.systemBarsCompat
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isHeightCompact
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Shapes back gesture progress into travel. Matches the platform's IME hide curve. */
|
||||
private val BACK_TRACKING_EASING = FastOutLinearInEasing
|
||||
|
||||
/** Settles the keyboard once a back gesture lets go. Lower stiffness is heavier. */
|
||||
private val BACK_SETTLE_MOTION = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
/** How long to hold space for a system keyboard that was asked for but never appeared. */
|
||||
private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds
|
||||
|
||||
private val SHEET_POSITIONAL_THRESHOLD = 56.dp
|
||||
private val SHEET_VELOCITY_THRESHOLD = 125.dp
|
||||
|
||||
/**
|
||||
* Displays [content] alongside keyboards of our own that stand in for the system keyboard.
|
||||
*
|
||||
* Only one is ever up, and never alongside the system keyboard. The scaffold owns every window inset,
|
||||
* handing [content] an already-inset space to lay out in.
|
||||
*
|
||||
* @param controller Requests which keyboard is up. Stable, so view code may hold one.
|
||||
* @param onEvent Receives everything the host may need to react to.
|
||||
* @param keyboardsProvider Declares the available keyboards.
|
||||
* @param keyboardHeight Bounds on how tall a keyboard may be.
|
||||
* @param adjustContentForInput False to let a keyboard cover [content] rather than resize it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MediaKeyboardScaffold(
|
||||
controller: MediaKeyboardController,
|
||||
onEvent: (MediaKeyboardEvents) -> Unit,
|
||||
keyboardsProvider: MediaKeyboardScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardHeight: MediaKeyboardHeight = MediaKeyboardHeight(),
|
||||
adjustContentForInput: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val registry = remember(keyboardsProvider) { MediaKeyboardRegistry().apply(keyboardsProvider) }
|
||||
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val imeAnimationTarget = WindowInsets.imeAnimationTarget
|
||||
val windowHeightPx = LocalWindowInfo.current.containerSize.height
|
||||
|
||||
val resources = LocalResources.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val isHeightCompact = remember(resources, configuration) { resources.getWindowSizeClass().isHeightCompact }
|
||||
|
||||
val minimumHeightPx = with(density) { keyboardHeight.minimum.roundToPx() }
|
||||
val topMarginPx = with(density) { keyboardHeight.topMargin.roundToPx() }
|
||||
|
||||
val activeKey = controller.current?.takeIf { registry.isEnabled(it) }
|
||||
|
||||
// The target, so a hide reads as gone the moment it is asked for rather than a whole animation later.
|
||||
val systemKeyboardVisible = imeAnimationTarget.getBottom(density) > 0
|
||||
|
||||
// Whether the live inset has yet to catch up with the target. Deliberately not imeAnimationSource,
|
||||
// which the platform leaves behind whenever an animation ends without a duration to run down, and
|
||||
// which would then read as animating until the next keyboard came and went. Derived, so the frames
|
||||
// the live inset walks through are not each a recomposition.
|
||||
val systemKeyboardAnimating by remember(imeInsets, imeAnimationTarget, density) {
|
||||
derivedStateOf { imeInsets.getBottom(density) != imeAnimationTarget.getBottom(density) }
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
controller.isSystemKeyboardVisible = systemKeyboardVisible
|
||||
}
|
||||
|
||||
// The controller outlives us. current is left set so a rebuilt view restores its keyboard.
|
||||
DisposableEffect(controller) {
|
||||
onDispose {
|
||||
controller.isSystemKeyboardVisible = false
|
||||
controller.awaitingSystemKeyboard = false
|
||||
}
|
||||
}
|
||||
|
||||
var hasReportedKeyboardVisibility by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(systemKeyboardVisible) {
|
||||
if (hasReportedKeyboardVisibility) {
|
||||
onEvent(MediaKeyboardEvents.SystemKeyboardVisibilityChanged(systemKeyboardVisible))
|
||||
}
|
||||
hasReportedKeyboardVisibility = true
|
||||
}
|
||||
|
||||
LaunchedEffect(controller.awaitingSystemKeyboard) {
|
||||
if (controller.awaitingSystemKeyboard) {
|
||||
delay(SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT)
|
||||
controller.awaitingSystemKeyboard = false
|
||||
}
|
||||
}
|
||||
|
||||
// From the animation target, not the live inset, which walks down through every closing frame.
|
||||
LaunchedEffect(imeAnimationTarget, density, minimumHeightPx, isHeightCompact) {
|
||||
if (isHeightCompact) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
snapshotFlow { imeAnimationTarget.getBottom(density) }
|
||||
.filter { it > minimumHeightPx }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
controller.keyboardHeightPx = it
|
||||
onEvent(MediaKeyboardEvents.SystemKeyboardHeightMeasured(it))
|
||||
}
|
||||
}
|
||||
|
||||
// dropWhile rather than drop, so a composition that starts mid-animation still reports the settle
|
||||
// it is in the middle of instead of swallowing it as the initial state.
|
||||
LaunchedEffect(imeInsets, imeAnimationTarget, density) {
|
||||
snapshotFlow { imeInsets.getBottom(density) == imeAnimationTarget.getBottom(density) }
|
||||
.distinctUntilChanged()
|
||||
.dropWhile { settled -> settled }
|
||||
.filter { settled -> settled }
|
||||
.collect {
|
||||
controller.awaitingSystemKeyboard = false
|
||||
onEvent(MediaKeyboardEvents.SystemKeyboardAnimationEnded)
|
||||
}
|
||||
}
|
||||
|
||||
val heightPx = keyboardHeight.resolve(
|
||||
preferredPx = controller.keyboardHeightPx,
|
||||
windowHeightPx = windowHeightPx,
|
||||
minimumPx = minimumHeightPx,
|
||||
topMarginPx = topMarginPx
|
||||
)
|
||||
val height = with(density) { heightPx.toDp() }
|
||||
|
||||
val backProgress = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sheetState = remember {
|
||||
SheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
positionalThreshold = { with(density) { SHEET_POSITIONAL_THRESHOLD.toPx() } },
|
||||
velocityThreshold = { with(density) { SHEET_VELOCITY_THRESHOLD.toPx() } },
|
||||
initialValue = SheetValue.Hidden,
|
||||
skipHiddenState = false
|
||||
)
|
||||
}
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState)
|
||||
|
||||
// This just makes sure the previously visible state doesn't go away too early while we're mid swap.
|
||||
var visibleKey by remember { mutableStateOf<MediaKeyboardKey?>(null) }
|
||||
|
||||
LaunchedEffect(activeKey) {
|
||||
if (activeKey != null) {
|
||||
if (visibleKey != null && visibleKey != activeKey) {
|
||||
onEvent(MediaKeyboardEvents.KeyboardHidden)
|
||||
}
|
||||
|
||||
visibleKey = activeKey
|
||||
backProgress.snapTo(0f)
|
||||
sheetState.expand()
|
||||
onEvent(MediaKeyboardEvents.KeyboardShown(activeKey))
|
||||
} else if (visibleKey != null) {
|
||||
// A gesture may already have carried it off screen; hold that until the hide completes.
|
||||
sheetState.hide()
|
||||
backProgress.snapTo(0f)
|
||||
visibleKey = null
|
||||
onEvent(MediaKeyboardEvents.KeyboardHidden)
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(enabled = activeKey != null) { progress ->
|
||||
try {
|
||||
progress.collect { backEvent -> backProgress.snapTo(BACK_TRACKING_EASING.transform(backEvent.progress)) }
|
||||
|
||||
backProgress.animateTo(1f, BACK_SETTLE_MOTION)
|
||||
controller.hide()
|
||||
onEvent(MediaKeyboardEvents.DismissedByBack)
|
||||
} catch (cancelled: CancellationException) {
|
||||
// PredictiveBackHandler cancels this job, so the unwind must run somewhere that outlives it.
|
||||
scope.launch { backProgress.animateTo(0f, BACK_SETTLE_MOTION) }
|
||||
throw cancelled
|
||||
}
|
||||
}
|
||||
|
||||
val systemKeyboardTakingOverSpace = activeKey == null &&
|
||||
(controller.awaitingSystemKeyboard || (systemKeyboardVisible && systemKeyboardAnimating))
|
||||
|
||||
val claimedBottomPx = {
|
||||
if (activeKey != null) {
|
||||
(heightPx * (1f - backProgress.value)).roundToInt().coerceAtLeast(0)
|
||||
} else if (systemKeyboardTakingOverSpace) {
|
||||
heightPx
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
var ancestorConsumedBottomPx by remember { mutableIntStateOf(0) }
|
||||
val safeDrawingInsets = WindowInsets.safeDrawingCompat
|
||||
|
||||
val windowInsets = if (adjustContentForInput) {
|
||||
safeDrawingInsets
|
||||
} else {
|
||||
WindowInsets.systemBarsCompat.add(WindowInsets.displayCutout)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetShape = RectangleShape,
|
||||
sheetDragHandle = null,
|
||||
sheetSwipeEnabled = false,
|
||||
sheetContainerColor = Color.Transparent,
|
||||
sheetTonalElevation = 0.dp,
|
||||
sheetShadowElevation = 0.dp,
|
||||
containerColor = Color.Transparent,
|
||||
sheetContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.graphicsLayer { translationY = backProgress.value * heightPx }
|
||||
.background(registry.containerColorFor(visibleKey).takeOrElse { MaterialTheme.colorScheme.surfaceContainerLow })
|
||||
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
|
||||
) {
|
||||
registry.contentFor(visibleKey)?.invoke()
|
||||
}
|
||||
}
|
||||
) { _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) }
|
||||
.windowInsetsPadding(windowInsets)
|
||||
.layout { measurable, constraints ->
|
||||
// Window insets are already out of these constraints; take only the excess claim.
|
||||
val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0)
|
||||
val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0
|
||||
|
||||
val available = (constraints.maxHeight - extraPx).coerceAtLeast(0)
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds on how tall a keyboard may be. The height it wants comes from
|
||||
* [MediaKeyboardController.keyboardHeightPx].
|
||||
*
|
||||
* Holds a lambda, so callers should [remember] it or the scaffold cannot skip recomposition.
|
||||
*
|
||||
* @param minimum Floor, used before any keyboard has been measured.
|
||||
* @param topMargin Kept clear of the top of the window, capping the height.
|
||||
* @param overrideForWindow Derives a height from the window instead, for windows unlike the one a
|
||||
* keyboard was measured in.
|
||||
*/
|
||||
@Immutable
|
||||
data class MediaKeyboardHeight(
|
||||
val minimum: Dp = 260.dp,
|
||||
val topMargin: Dp = 170.dp,
|
||||
val overrideForWindow: ((windowHeightPx: Int) -> Int)? = null
|
||||
) {
|
||||
internal fun resolve(preferredPx: Int, windowHeightPx: Int, minimumPx: Int, topMarginPx: Int): Int {
|
||||
if (windowHeightPx <= 0) {
|
||||
return maxOf(preferredPx, minimumPx)
|
||||
}
|
||||
|
||||
val maximumPx = (windowHeightPx - topMarginPx).coerceAtLeast(minimumPx)
|
||||
|
||||
overrideForWindow?.let { return it(windowHeightPx).coerceAtMost(maximumPx) }
|
||||
|
||||
return preferredPx.coerceIn(minimumPx, maximumPx)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose.mediakeyboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** Declares which keyboards a [MediaKeyboardScaffold] offers. */
|
||||
interface MediaKeyboardScope {
|
||||
/**
|
||||
* Offers the keyboard identified by [key].
|
||||
*
|
||||
* @param key Identifies the keyboard.
|
||||
* @param enabled False to keep the key known but refuse requests for it.
|
||||
* @param containerColor Fills the sheet, navigation bar included, so it should match whatever
|
||||
* [content] paints its edges with. Unspecified falls back to the scaffold's surface.
|
||||
* @param content The keyboard itself.
|
||||
*/
|
||||
fun keyboard(
|
||||
key: MediaKeyboardKey,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = Color.Unspecified,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
internal class MediaKeyboardRegistry : MediaKeyboardScope {
|
||||
private val entries = LinkedHashMap<MediaKeyboardKey, Entry>()
|
||||
|
||||
override fun keyboard(
|
||||
key: MediaKeyboardKey,
|
||||
enabled: Boolean,
|
||||
containerColor: Color,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
entries[key] = Entry(enabled, containerColor, content)
|
||||
}
|
||||
|
||||
fun isEnabled(key: MediaKeyboardKey?): Boolean = key != null && entries[key]?.enabled == true
|
||||
|
||||
fun contentFor(key: MediaKeyboardKey?): (@Composable () -> Unit)? {
|
||||
return key?.let { entries[it] }?.takeIf { it.enabled }?.content
|
||||
}
|
||||
|
||||
fun containerColorFor(key: MediaKeyboardKey?): Color {
|
||||
return key?.let { entries[it] }?.takeIf { it.enabled }?.containerColor ?: Color.Unspecified
|
||||
}
|
||||
|
||||
private class Entry(val enabled: Boolean, val containerColor: Color, val content: @Composable () -> Unit)
|
||||
}
|
||||
@@ -8,12 +8,12 @@ package org.thoughtcrime.securesms.conversation.v2
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Adapts [MediaKeyboardController] to the conversation's view code, which asks for keyboards from
|
||||
* Adapts [KeyboardSheetController] to the conversation's view code, which asks for keyboards from
|
||||
* click listeners rather than from composition.
|
||||
*
|
||||
* @param context Used to hide the system keyboard.
|
||||
@@ -21,14 +21,11 @@ import org.thoughtcrime.securesms.util.ViewUtil
|
||||
*/
|
||||
class ChatInputController(
|
||||
private val context: Context,
|
||||
private val controller: MediaKeyboardController
|
||||
private val controller: KeyboardSheetController
|
||||
) {
|
||||
|
||||
private var wasKeyboardVisibleBeforeToggle: Boolean = false
|
||||
|
||||
private val listeners: MutableSet<Listener> = mutableSetOf()
|
||||
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
|
||||
|
||||
/** What [runAfterAllHidden] is waiting on. */
|
||||
private var pendingHiddenAction: (() -> Unit)? = null
|
||||
|
||||
@@ -38,49 +35,34 @@ class ChatInputController(
|
||||
val isKeyboardShowing: Boolean
|
||||
get() = controller.isSystemKeyboardVisible
|
||||
|
||||
fun addInputListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
/** See [KeyboardSheetController.onHostWindowShown]. */
|
||||
fun onHostWindowShown() {
|
||||
controller.onHostWindowShown()
|
||||
}
|
||||
|
||||
fun removeInputListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
/** See [KeyboardSheetController.onHostWindowHidden]. */
|
||||
fun onHostWindowHidden() {
|
||||
controller.onHostWindowHidden()
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners.remove(listener)
|
||||
}
|
||||
|
||||
/** Drops everything still listening, for a host whose view is going away. */
|
||||
fun clearListeners() {
|
||||
listeners.clear()
|
||||
keyboardStateListeners.clear()
|
||||
/** Drops anything still waiting on a hide, for a host whose view is going away. */
|
||||
fun clearPendingActions() {
|
||||
pendingHiddenAction = null
|
||||
controller.onHostWindowHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
* Told rather than observed: the host handles the scaffold's own stream, and only passes on the
|
||||
* two things [runAfterAllHidden] has to wait for.
|
||||
*/
|
||||
fun onKeyboardVisibilityChanged(visible: Boolean) {
|
||||
keyboardStateListeners.toList().forEach {
|
||||
if (visible) it.onKeyboardShown() else it.onKeyboardHidden()
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
runPendingHiddenAction()
|
||||
}
|
||||
}
|
||||
|
||||
fun onKeyboardAnimationEnded() {
|
||||
keyboardStateListeners.toList().forEach { it.onKeyboardAnimationEnded() }
|
||||
}
|
||||
|
||||
fun onInputShown(key: MediaKeyboardKey) {
|
||||
listeners.toList().forEach { it.onInputShown(key) }
|
||||
}
|
||||
|
||||
/** See [onKeyboardVisibilityChanged]. */
|
||||
fun onInputHidden() {
|
||||
listeners.toList().forEach { it.onInputHidden() }
|
||||
runPendingHiddenAction()
|
||||
}
|
||||
|
||||
@@ -135,7 +117,7 @@ class ChatInputController(
|
||||
* @param imeTarget The field the system keyboard belongs to.
|
||||
* @param showSoftKeyOnHide Whether the system keyboard replaces [key] when it is taken away.
|
||||
*/
|
||||
fun toggleInput(key: MediaKeyboardKey, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
|
||||
fun toggleInput(key: KeyboardSheetKey, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
|
||||
// Ours can sit behind the system keyboard rather than in place of it, where hiding it would act
|
||||
// on something the user cannot see. The system keyboard goes instead.
|
||||
if (controller.current == key && !isKeyboardShowing) {
|
||||
@@ -150,15 +132,4 @@ class ChatInputController(
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onInputShown(key: MediaKeyboardKey)
|
||||
fun onInputHidden()
|
||||
}
|
||||
|
||||
interface KeyboardStateListener {
|
||||
fun onKeyboardShown()
|
||||
fun onKeyboardHidden()
|
||||
fun onKeyboardAnimationEnded() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
|
||||
|
||||
/** The keyboards the conversation offers in place of the system keyboard. */
|
||||
object ChatKeyboards {
|
||||
val Media = MediaKeyboardKey("conversation.media")
|
||||
val Attachment = MediaKeyboardKey("conversation.attachment")
|
||||
val Media = KeyboardSheetKey("conversation.media")
|
||||
val Attachment = KeyboardSheetKey("conversation.attachment")
|
||||
}
|
||||
|
||||
@@ -26,20 +26,24 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetHeight
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetScaffold
|
||||
import org.signal.core.ui.compose.navigationBarsCompat
|
||||
import org.signal.core.ui.compose.safeDrawingCompat
|
||||
import org.signal.core.ui.compose.statusBarsCompat
|
||||
import org.signal.core.ui.util.ThemeUtil
|
||||
import org.signal.mediakeyboard.MediaKeyboard
|
||||
import org.signal.mediakeyboard.MediaKeyboardAction
|
||||
import org.signal.mediakeyboard.MediaKeyboardTab
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardHeight
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardScaffold
|
||||
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A bubble's keyboard takes a little over half the window, as it does for the older hosts. */
|
||||
@@ -48,8 +52,17 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f
|
||||
/**
|
||||
* Displays a chat screen for a given conversation.
|
||||
*
|
||||
* @param controller The MediaKeyboardController to control the media keyboard
|
||||
* @param onEvent The MediaKeyboard events stream for interacting with the media keyboard
|
||||
* @param controller The KeyboardSheetController to control the media keyboard
|
||||
* @param onScaffoldAction Receives what the scaffold itself reports: which keyboard is up, and
|
||||
* what the system keyboard is doing
|
||||
* @param mediaKeyboardRepository Supplies the media keyboard's emoji, sticker, and gif data
|
||||
* @param onMediaKeyboardAction Receives what the user picks from the media keyboard, and anything
|
||||
* only the host can carry out
|
||||
* @param mediaKeyboardTabs The only media keyboard tabs to offer, or null for all of them
|
||||
* @param mediaKeyboardInitialTab The media keyboard tab to open on, from the mode remembered across
|
||||
* runs, or null to open on the first one offered
|
||||
* @param minimumVisibleContentPx How much of [contentView] the expanded media keyboard must leave
|
||||
* in view: message rows, plus the toolbar and input panel that sit either side of them
|
||||
* @param scrim The color information for the top and bottom scrim
|
||||
* @param isBubble Whether we're displaying content in a bubble
|
||||
* @param conversationView The conversation's own view hierarchy, and the only interop view here.
|
||||
@@ -58,8 +71,13 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
controller: MediaKeyboardController,
|
||||
onEvent: (MediaKeyboardEvents) -> Unit,
|
||||
controller: KeyboardSheetController,
|
||||
onScaffoldAction: (KeyboardSheetAction) -> Unit,
|
||||
mediaKeyboardRepository: MediaKeyboardRepository,
|
||||
onMediaKeyboardAction: (MediaKeyboardAction) -> Unit,
|
||||
mediaKeyboardTabs: Set<MediaKeyboardTab>?,
|
||||
mediaKeyboardInitialTab: MediaKeyboardTab?,
|
||||
minimumVisibleContentPx: Int,
|
||||
scrims: ChatScrimState,
|
||||
isBubble: Boolean,
|
||||
conversationView: View,
|
||||
@@ -71,10 +89,13 @@ fun ChatScreen(
|
||||
val mediaKeyboardColor = Color(ThemeUtil.getThemedColor(LocalContext.current, R.attr.mediaKeyboardBottomBarBackgroundColor))
|
||||
val attachmentKeyboardColor = scrims.attachmentKeyboardColor
|
||||
|
||||
val keyboardHeight = remember(minimumHeight, topMargin, isBubble) {
|
||||
MediaKeyboardHeight(
|
||||
val minimumVisibleContent = with(LocalDensity.current) { minimumVisibleContentPx.toDp() }
|
||||
|
||||
val keyboardHeight = remember(minimumHeight, topMargin, minimumVisibleContent, isBubble) {
|
||||
KeyboardSheetHeight(
|
||||
minimum = minimumHeight,
|
||||
topMargin = topMargin,
|
||||
minimumContentVisible = minimumVisibleContent,
|
||||
overrideForWindow = if (isBubble) {
|
||||
{ windowHeightPx -> (windowHeightPx * BUBBLE_HEIGHT_FRACTION).roundToInt() }
|
||||
} else {
|
||||
@@ -106,17 +127,20 @@ fun ChatScreen(
|
||||
.background(Color(scrims.navigationBarColor))
|
||||
)
|
||||
|
||||
MediaKeyboardScaffold(
|
||||
KeyboardSheetScaffold(
|
||||
controller = controller,
|
||||
onEvent = onEvent,
|
||||
onAction = onScaffoldAction,
|
||||
keyboardsProvider = {
|
||||
keyboard(
|
||||
key = ChatKeyboards.Media,
|
||||
containerColor = mediaKeyboardColor
|
||||
containerColor = mediaKeyboardColor,
|
||||
expandable = true
|
||||
) {
|
||||
AndroidFragment(
|
||||
clazz = KeyboardPagerFragment::class.java,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
MediaKeyboard(
|
||||
repository = mediaKeyboardRepository,
|
||||
onAction = onMediaKeyboardAction,
|
||||
tabs = mediaKeyboardTabs,
|
||||
initialTab = mediaKeyboardInitialTab
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,13 @@ import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
@@ -75,7 +80,6 @@ import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -121,6 +125,8 @@ import org.signal.core.models.database.StickerRecord
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetController
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isSplitPane
|
||||
@@ -146,6 +152,8 @@ import org.signal.core.util.requireParcelableCompat
|
||||
import org.signal.core.util.setActionItemTint
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.emoji.EmojiEventListener
|
||||
import org.signal.mediakeyboard.MediaKeyboardAction
|
||||
import org.signal.mediakeyboard.MediaKeyboardTab
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
@@ -173,10 +181,6 @@ import org.thoughtcrime.securesms.components.SendButton
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents
|
||||
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
@@ -259,6 +263,7 @@ import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
|
||||
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
|
||||
import org.thoughtcrime.securesms.database.DraftTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
@@ -278,6 +283,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelActivity
|
||||
@@ -295,12 +302,7 @@ import org.thoughtcrime.securesms.invites.InviteActions
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentBackfill
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.sticker.StickerSearchDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
@@ -312,6 +314,7 @@ import org.thoughtcrime.securesms.main.MainNavigationEventSink
|
||||
import org.thoughtcrime.securesms.main.MainNavigationEvents
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import org.thoughtcrime.securesms.mediakeyboard.SignalMediaKeyboardRepository
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewActivity
|
||||
@@ -397,6 +400,7 @@ import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
@@ -419,13 +423,9 @@ class ConversationFragment :
|
||||
LoggingFragment(),
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
||||
ReactionsBottomSheetDialogFragment.Callback,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiEventListener,
|
||||
GifKeyboardPageFragment.Host,
|
||||
StickerEventListener,
|
||||
StickerKeyboardPageFragment.Callback,
|
||||
MediaKeyboard.MediaKeyboardListener,
|
||||
EmojiSearchFragment.Callback,
|
||||
StickerSearchDialogFragment.Callback,
|
||||
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
|
||||
ScheduleMessageDialogCallback,
|
||||
ConversationBottomSheetCallback,
|
||||
@@ -442,9 +442,11 @@ class ConversationFragment :
|
||||
|
||||
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
|
||||
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
|
||||
private const val EMOJI_SEARCH_FRAGMENT_TAG = "EmojiSearchFragment"
|
||||
private const val MESSAGE_DETAILS_TAG = "MessageDetailsFragment"
|
||||
|
||||
/** Message rows the expanded media keyboard has to leave in view. */
|
||||
private const val MINIMUM_VISIBLE_MESSAGES_DP: Int = 96
|
||||
|
||||
private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L
|
||||
private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4
|
||||
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
|
||||
@@ -540,8 +542,6 @@ class ConversationFragment :
|
||||
ConversationSearchViewModel(getString(R.string.note_to_self))
|
||||
}
|
||||
|
||||
private val keyboardPagerViewModel: KeyboardPagerViewModel by activityViewModels()
|
||||
|
||||
private val stickerViewModel: StickerSuggestionsViewModel by viewModel {
|
||||
StickerSuggestionsViewModel()
|
||||
}
|
||||
@@ -603,14 +603,13 @@ class ConversationFragment :
|
||||
viewModel.setIsSearchRequested(value)
|
||||
}
|
||||
|
||||
/** The keyboard the toggle showed before edit mode forced it to emoji. */
|
||||
private var previousPage: KeyboardPage? = null
|
||||
private var previousPages: Set<KeyboardPage>? = null
|
||||
private var reShowScheduleMessagesBar: Boolean = false
|
||||
private var composeTextEventsListener: ComposeTextEventsListener? = null
|
||||
private var dataObserver: DataObserver? = null
|
||||
private var menuProvider: ConversationOptionsMenu.Provider? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
private var keyboardEvents: KeyboardEvents? = null
|
||||
private var progressDialog: ProgressCardDialogFragment? = null
|
||||
private var firstPinRender: Boolean = true
|
||||
private var skipNextBackPressHandling: Boolean = false
|
||||
@@ -638,8 +637,8 @@ class ConversationFragment :
|
||||
private val chatScreenViewModel: ChatScreenViewModel by viewModels()
|
||||
|
||||
/** Stable across recomposition, so view code can ask for a keyboard from a click listener. */
|
||||
private val mediaKeyboardController: MediaKeyboardController by lazy(LazyThreadSafetyMode.NONE) {
|
||||
MediaKeyboardController(
|
||||
private val mediaKeyboardController: KeyboardSheetController by lazy(LazyThreadSafetyMode.NONE) {
|
||||
KeyboardSheetController(
|
||||
initialKeyboardHeightPx = chatScreenViewModel.getStoredKeyboardHeight(
|
||||
isLandscape = isLandscape(),
|
||||
minimumPx = resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
|
||||
@@ -650,6 +649,101 @@ class ConversationFragment :
|
||||
/** Colours for the scrims ChatScreen paints. */
|
||||
private val chatScrims = ChatScrimState()
|
||||
|
||||
/**
|
||||
* Narrows the media keyboard while editing a message, where an edit can only carry text, so
|
||||
* stickers and gifs have nothing to do. Null the rest of the time.
|
||||
*/
|
||||
private var mediaKeyboardTabs by mutableStateOf<Set<MediaKeyboardTab>?>(null)
|
||||
|
||||
/**
|
||||
* How much room the expanded media keyboard has to leave the conversation. The toolbar and input
|
||||
* panel are views, so only the fragment can measure them; the rest is message rows.
|
||||
*/
|
||||
private var minimumVisibleContentPx by mutableIntStateOf(MINIMUM_VISIBLE_MESSAGES_DP.dp)
|
||||
|
||||
private fun updateMinimumVisibleContent() {
|
||||
if (view == null) {
|
||||
return
|
||||
}
|
||||
|
||||
minimumVisibleContentPx = MINIMUM_VISIBLE_MESSAGES_DP.dp + binding.toolbar.bottom.coerceAtLeast(0) + inputPanel.height
|
||||
}
|
||||
|
||||
private val mediaKeyboardRepository: SignalMediaKeyboardRepository by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SignalMediaKeyboardRepository(requireContext(), recentEmojis)
|
||||
}
|
||||
|
||||
private fun onMediaKeyboardAction(action: MediaKeyboardAction) {
|
||||
when (action) {
|
||||
is MediaKeyboardAction.EmojiSelected -> inputPanel.onEmojiSelected(action.emoji)
|
||||
|
||||
MediaKeyboardAction.Backspace -> inputPanel.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
|
||||
|
||||
is MediaKeyboardAction.StickerSelected -> {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val record = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.stickers.getSticker(action.sticker.packId, action.sticker.stickerId.toInt(), false)
|
||||
}
|
||||
|
||||
if (record != null) {
|
||||
sendSticker(stickerRecord = record, clearCompose = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaKeyboardAction.GifSelected -> {
|
||||
val image = mediaKeyboardRepository.gifs.getGiphyImage(action.gif.id)
|
||||
if (image != null) {
|
||||
container.hideInput()
|
||||
giphyMp4ViewModel.saveToBlob(image)
|
||||
}
|
||||
}
|
||||
|
||||
MediaKeyboardAction.StickerManagementClicked -> {
|
||||
StickerManagementScreen.show(this)
|
||||
container.hideInput()
|
||||
}
|
||||
|
||||
// A child of this fragment so the dialog's own listener lookups land back here.
|
||||
MediaKeyboardAction.StickerSearchClicked -> {
|
||||
container.onHostWindowShown()
|
||||
StickerSearchDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
|
||||
is MediaKeyboardAction.ViewStickerPackClicked -> {
|
||||
startActivity(StickerPackPreviewActivityV2.createIntent(StickerPackId(action.packId), StickerPackKey(action.packKey)))
|
||||
}
|
||||
|
||||
// A screen of its own rather than a window over this one, and picking a gif carries on into
|
||||
// media send, so the keyboard has no reason to still be up on the way back.
|
||||
MediaKeyboardAction.GifSearchClicked -> {
|
||||
val recipientId = viewModel.recipientSnapshot?.id ?: return
|
||||
container.hideInput()
|
||||
conversationActivityResultContracts.launchGifSearch(recipientId, composeText.textTrimmed)
|
||||
}
|
||||
|
||||
// Moves the input panel's toggle icon and persists the mode, so the keyboard opens on this tab
|
||||
// next time.
|
||||
is MediaKeyboardAction.TabSelected -> onKeyboardChanged(
|
||||
when (action.tab) {
|
||||
MediaKeyboardTab.EMOJI -> KeyboardPage.EMOJI
|
||||
MediaKeyboardTab.STICKER -> KeyboardPage.STICKER
|
||||
MediaKeyboardTab.GIF -> KeyboardPage.GIF
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns a gif picked from the media keyboard into a blob the composer can attach. */
|
||||
private val giphyMp4ViewModel: GiphyMp4ViewModel by activityViewModels { GiphyMp4ViewModel.Factory(isMms()) }
|
||||
|
||||
private var gifProgressDialog: AlertDialog? = null
|
||||
|
||||
private fun dismissGifProgressDialog() {
|
||||
gifProgressDialog?.dismiss()
|
||||
gifProgressDialog = null
|
||||
}
|
||||
|
||||
private val container: ChatInputController by lazy(LazyThreadSafetyMode.NONE) {
|
||||
ChatInputController(requireContext(), mediaKeyboardController)
|
||||
}
|
||||
@@ -725,13 +819,22 @@ class ConversationFragment :
|
||||
conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false)
|
||||
conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false)
|
||||
|
||||
// Read once here rather than in composition: it only matters when the keyboard's view model is
|
||||
// first created, and it comes from disk.
|
||||
val initialKeyboardTab = preferredKeyboardPage().toMediaKeyboardTab()
|
||||
|
||||
val composition = ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
SignalTheme {
|
||||
ChatScreen(
|
||||
controller = mediaKeyboardController,
|
||||
onEvent = ::onMediaKeyboardEvent,
|
||||
onScaffoldAction = ::onKeyboardSheetAction,
|
||||
mediaKeyboardRepository = mediaKeyboardRepository,
|
||||
onMediaKeyboardAction = ::onMediaKeyboardAction,
|
||||
mediaKeyboardTabs = mediaKeyboardTabs,
|
||||
mediaKeyboardInitialTab = initialKeyboardTab,
|
||||
minimumVisibleContentPx = minimumVisibleContentPx,
|
||||
scrims = chatScrims,
|
||||
isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE,
|
||||
conversationView = conversationContent,
|
||||
@@ -778,14 +881,43 @@ class ConversationFragment :
|
||||
return origin
|
||||
}
|
||||
|
||||
private fun onMediaKeyboardEvent(event: MediaKeyboardEvents) {
|
||||
when (event) {
|
||||
is MediaKeyboardEvents.SystemKeyboardVisibilityChanged -> container.onKeyboardVisibilityChanged(event.visible)
|
||||
MediaKeyboardEvents.SystemKeyboardAnimationEnded -> container.onKeyboardAnimationEnded()
|
||||
is MediaKeyboardEvents.SystemKeyboardHeightMeasured -> chatScreenViewModel.setKeyboardHeight(isLandscape(), event.heightPx)
|
||||
is MediaKeyboardEvents.KeyboardShown -> container.onInputShown(event.key)
|
||||
MediaKeyboardEvents.KeyboardHidden -> container.onInputHidden()
|
||||
MediaKeyboardEvents.DismissedByBack -> Unit
|
||||
private fun onKeyboardSheetAction(action: KeyboardSheetAction) {
|
||||
when (action) {
|
||||
is KeyboardSheetAction.SystemKeyboardVisibilityChanged -> {
|
||||
// The toggle follows what is on screen: the system keyboard can cover one of ours, not just
|
||||
// replace it.
|
||||
inputPanel.setMediaKeyboardToggleOffersIme(!action.visible && container.isInputShowing)
|
||||
|
||||
// The open search field owns the keyboard, so it follows it in and out.
|
||||
if (searchMenuItem?.isActionViewExpanded == true) {
|
||||
val searchView = searchMenuItem?.actionView
|
||||
if (action.visible && searchView?.hasFocus() == false) {
|
||||
searchView.requestFocus()
|
||||
} else if (!action.visible && searchView?.hasFocus() == true) {
|
||||
searchView.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
container.onKeyboardVisibilityChanged(action.visible)
|
||||
}
|
||||
|
||||
is KeyboardSheetAction.SystemKeyboardHeightMeasured -> chatScreenViewModel.setKeyboardHeight(isLandscape(), action.heightPx)
|
||||
|
||||
is KeyboardSheetAction.KeyboardShown -> {
|
||||
if (action.key == ChatKeyboards.Media) {
|
||||
onShown()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardSheetAction.KeyboardHidden -> {
|
||||
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
|
||||
onHidden()
|
||||
container.onInputHidden()
|
||||
}
|
||||
|
||||
// Nothing hangs off these, but a scaffold has every reason to report them.
|
||||
KeyboardSheetAction.SystemKeyboardAnimationEnded,
|
||||
KeyboardSheetAction.DismissedByBack -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,9 +1034,33 @@ class ConversationFragment :
|
||||
}
|
||||
applyToolbarPaddingRunnable = runnable
|
||||
rv.post(runnable)
|
||||
|
||||
updateMinimumVisibleContent()
|
||||
}
|
||||
|
||||
inputPanel.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateMinimumVisibleContent()
|
||||
}
|
||||
|
||||
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
|
||||
|
||||
giphyMp4ViewModel.saveResultEvents.observe(viewLifecycleOwner) { result ->
|
||||
when (result) {
|
||||
is GiphyMp4SaveResult.Success -> {
|
||||
dismissGifProgressDialog()
|
||||
onGifSelectSuccess(result.blobUri, result.width, result.height)
|
||||
}
|
||||
|
||||
is GiphyMp4SaveResult.Error -> {
|
||||
dismissGifProgressDialog()
|
||||
toast(R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, toastDuration = Toast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
else -> {
|
||||
gifProgressDialog = SimpleProgressDialog.show(requireContext())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
@@ -986,14 +1142,10 @@ class ConversationFragment :
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewModel.collapseAllEvents()
|
||||
keyboardEvents?.let {
|
||||
container.removeInputListener(it)
|
||||
container.removeKeyboardStateListener(it)
|
||||
}
|
||||
keyboardEvents = null
|
||||
|
||||
// Fragment-scoped, so anything still waiting on a hide would outlive the binding.
|
||||
container.clearListeners()
|
||||
container.clearPendingActions()
|
||||
|
||||
dismissGifProgressDialog()
|
||||
|
||||
if (!requireActivity().isChangingConfigurations) {
|
||||
(requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss()
|
||||
@@ -1072,24 +1224,6 @@ class ConversationFragment :
|
||||
clearFocusedItem()
|
||||
}
|
||||
|
||||
override fun openEmojiSearch() {
|
||||
val fragment = childFragmentManager.findFragmentByTag(EMOJI_SEARCH_FRAGMENT_TAG)
|
||||
if (fragment == null) {
|
||||
childFragmentManager.commit {
|
||||
add(R.id.emoji_search_container, EmojiSearchFragment(), EMOJI_SEARCH_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeEmojiSearch() {
|
||||
val fragment = childFragmentManager.findFragmentByTag(EMOJI_SEARCH_FRAGMENT_TAG)
|
||||
if (fragment != null) {
|
||||
childFragmentManager.commit(allowStateLoss = true) {
|
||||
remove(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(emoji: String?) {
|
||||
if (emoji != null) {
|
||||
inputPanel.onEmojiSelected(emoji)
|
||||
@@ -1103,10 +1237,6 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun openStickerSearch() {
|
||||
StickerSearchDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onStickerSelected(sticker: StickerRecord) {
|
||||
sendSticker(
|
||||
stickerRecord = sticker,
|
||||
@@ -1114,21 +1244,20 @@ class ConversationFragment :
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStickerSearchDismissed() {
|
||||
container.onHostWindowHidden()
|
||||
}
|
||||
|
||||
override fun onStickerManagementClicked() {
|
||||
StickerManagementScreen.show(this)
|
||||
container.hideInput()
|
||||
}
|
||||
|
||||
override fun isMms(): Boolean {
|
||||
private fun isMms(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun openGifSearch() {
|
||||
val recipientId = viewModel.recipientSnapshot?.id ?: return
|
||||
conversationActivityResultContracts.launchGifSearch(recipientId, composeText.textTrimmed)
|
||||
}
|
||||
|
||||
override fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int) {
|
||||
private fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int) {
|
||||
setMedia(
|
||||
uri = blobUri,
|
||||
mediaType = SlideFactory.MediaType.from(AppDependencies.blobs.getMimeType(blobUri))!!,
|
||||
@@ -1138,16 +1267,15 @@ class ConversationFragment :
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShown() {
|
||||
private fun onShown() {
|
||||
inputPanel.mediaKeyboardListener.onShown()
|
||||
}
|
||||
|
||||
override fun onHidden() {
|
||||
private fun onHidden() {
|
||||
inputPanel.mediaKeyboardListener.onHidden()
|
||||
closeEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onKeyboardChanged(page: KeyboardPage) {
|
||||
private fun onKeyboardChanged(page: KeyboardPage) {
|
||||
inputPanel.mediaKeyboardListener.onKeyboardChanged(page)
|
||||
}
|
||||
|
||||
@@ -1446,11 +1574,6 @@ class ConversationFragment :
|
||||
dataObserver = DataObserver()
|
||||
adapter.registerAdapterDataObserver(dataObserver!!)
|
||||
|
||||
keyboardEvents = KeyboardEvents().also {
|
||||
container.addInputListener(it)
|
||||
container.addKeyboardStateListener(it)
|
||||
}
|
||||
|
||||
childFragmentManager.setFragmentResultListener(AttachmentKeyboardFragment.RESULT_KEY, viewLifecycleOwner, AttachmentKeyboardFragmentListener())
|
||||
motionEventRelay.setDrain(MotionEventRelayDrain(this))
|
||||
|
||||
@@ -2505,21 +2628,28 @@ class ConversationFragment :
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
private fun initializeMediaKeyboard() {
|
||||
val keyboardMode: TextSecurePreferences.MediaKeyboardMode = TextSecurePreferences.getMediaKeyboardMode(requireContext())
|
||||
val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())
|
||||
private fun KeyboardPage.toMediaKeyboardTab(): MediaKeyboardTab {
|
||||
return when (this) {
|
||||
KeyboardPage.EMOJI -> MediaKeyboardTab.EMOJI
|
||||
KeyboardPage.STICKER -> MediaKeyboardTab.STICKER
|
||||
KeyboardPage.GIF -> MediaKeyboardTab.GIF
|
||||
}
|
||||
}
|
||||
|
||||
keyboardPagerViewModel.resetPages()
|
||||
inputPanel.showMediaKeyboardToggle(true)
|
||||
|
||||
val keyboardPage = when (keyboardMode) {
|
||||
/** Which keyboard the toggle should offer, from the mode remembered across runs. */
|
||||
private fun preferredKeyboardPage(): KeyboardPage {
|
||||
return when (TextSecurePreferences.getMediaKeyboardMode(requireContext())) {
|
||||
TextSecurePreferences.MediaKeyboardMode.EMOJI -> KeyboardPage.EMOJI
|
||||
TextSecurePreferences.MediaKeyboardMode.STICKER -> KeyboardPage.STICKER
|
||||
TextSecurePreferences.MediaKeyboardMode.GIF -> if (RemoteConfig.gifSearchAvailable) KeyboardPage.GIF else KeyboardPage.STICKER
|
||||
}
|
||||
}
|
||||
|
||||
inputPanel.setMediaKeyboardToggleMode(keyboardPage)
|
||||
keyboardPagerViewModel.switchToPage(keyboardPage)
|
||||
private fun initializeMediaKeyboard() {
|
||||
val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())
|
||||
|
||||
inputPanel.showMediaKeyboardToggle(true)
|
||||
inputPanel.setMediaKeyboardToggleMode(preferredKeyboardPage())
|
||||
|
||||
if (stickerIntro) {
|
||||
TextSecurePreferences.setMediaKeyboardMode(requireContext(), TextSecurePreferences.MediaKeyboardMode.STICKER)
|
||||
@@ -5228,9 +5358,9 @@ class ConversationFragment :
|
||||
|
||||
override fun onEnterEditMode() {
|
||||
updateToggleButtonState()
|
||||
previousPage = keyboardPagerViewModel.page().value
|
||||
previousPages = keyboardPagerViewModel.pages().value
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
previousPage = preferredKeyboardPage()
|
||||
mediaKeyboardTabs = setOf(MediaKeyboardTab.EMOJI)
|
||||
// Also persists the mode, so read [previousPage] before this and restore it on the way out.
|
||||
onKeyboardChanged(KeyboardPage.EMOJI)
|
||||
stickerViewModel.onInputTextUpdated("")
|
||||
updateLinkPreviewState()
|
||||
@@ -5239,13 +5369,9 @@ class ConversationFragment :
|
||||
override fun onExitEditMode() {
|
||||
updateToggleButtonState()
|
||||
draftViewModel.deleteMessageEditDraft()
|
||||
if (previousPages != null) {
|
||||
keyboardPagerViewModel.setPages(previousPages!!)
|
||||
previousPages = null
|
||||
}
|
||||
if (previousPage != null) {
|
||||
keyboardPagerViewModel.switchToPage(previousPage!!)
|
||||
onKeyboardChanged(previousPage!!)
|
||||
mediaKeyboardTabs = null
|
||||
previousPage?.let {
|
||||
onKeyboardChanged(it)
|
||||
previousPage = null
|
||||
}
|
||||
updateLinkPreviewState()
|
||||
@@ -5353,49 +5479,6 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private inner class KeyboardEvents :
|
||||
ChatInputController.Listener,
|
||||
ChatInputController.KeyboardStateListener {
|
||||
|
||||
override fun onInputShown(key: MediaKeyboardKey) {
|
||||
if (key == ChatKeyboards.Media) {
|
||||
onShown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInputHidden() {
|
||||
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
|
||||
onHidden()
|
||||
}
|
||||
|
||||
override fun onKeyboardShown() {
|
||||
// The toggle follows what is on screen: the system keyboard can cover one of ours, not just
|
||||
// replace it.
|
||||
inputPanel.setMediaKeyboardToggleOffersIme(false)
|
||||
|
||||
if (searchMenuItem?.isActionViewExpanded == true && searchMenuItem?.actionView?.hasFocus() == false) {
|
||||
searchMenuItem?.actionView?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
inputPanel.setMediaKeyboardToggleOffersIme(container.isInputShowing)
|
||||
|
||||
if (searchMenuItem?.isActionViewExpanded == true && searchMenuItem?.actionView?.hasFocus() == true) {
|
||||
searchMenuItem?.actionView?.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardAnimationEnded() {
|
||||
if (view == null) {
|
||||
return
|
||||
}
|
||||
if (!container.isKeyboardShowing) {
|
||||
closeEmojiSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Event Bus
|
||||
|
||||
@@ -850,7 +850,7 @@ class ConversationViewModel(
|
||||
data object Cancelled : PlaintextExportState
|
||||
}
|
||||
|
||||
/** A media keyboard is absent by design: MediaKeyboardScaffold registers its own back handler. */
|
||||
/** A media keyboard is absent by design: KeyboardSheetScaffold registers its own back handler. */
|
||||
data class BackPressedState(
|
||||
val isReactionDelegateShowing: Boolean = false,
|
||||
val isSearchRequested: Boolean = false,
|
||||
|
||||
@@ -370,6 +370,17 @@ class StickerTables(
|
||||
notifyStickerPackListeners()
|
||||
}
|
||||
|
||||
fun updateStickerLastUsedTime(packId: String, stickerId: Int, lastUsed: Long) {
|
||||
writableDatabase
|
||||
.update(Sticker.TABLE_NAME)
|
||||
.values(Sticker.LAST_USED to lastUsed)
|
||||
.where("${Sticker.PACK_ID} = ? AND ${Sticker.STICKER_ID} = ? AND ${Sticker.COVER} = 0", packId, stickerId)
|
||||
.run()
|
||||
|
||||
notifyStickerListeners()
|
||||
notifyStickerPackListeners()
|
||||
}
|
||||
|
||||
fun markPackAsInstalled(packId: String, notify: Boolean) {
|
||||
val transitioned = updatePackInstalled(
|
||||
db = databaseHelper.signalWritableDatabase,
|
||||
@@ -830,7 +841,7 @@ class StickerTables(
|
||||
val random: ByteArray
|
||||
)
|
||||
|
||||
class StickerRecordReader(private val cursor: Cursor) : Closeable {
|
||||
class StickerRecordReader(private val cursor: Cursor) : Closeable, Iterable<StickerRecord> {
|
||||
|
||||
fun getNext(): StickerRecord? {
|
||||
if (!cursor.moveToNext()) {
|
||||
@@ -853,9 +864,31 @@ class StickerTables(
|
||||
)
|
||||
}
|
||||
|
||||
fun asSequence(): Sequence<StickerRecord> = sequence {
|
||||
var record = getNext()
|
||||
while (record != null) {
|
||||
yield(record)
|
||||
record = getNext()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<StickerRecord> {
|
||||
return ReaderIterator()
|
||||
}
|
||||
|
||||
private inner class ReaderIterator : Iterator<StickerRecord> {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count != 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): StickerRecord {
|
||||
return getNext() ?: throw NoSuchElementException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StickerPackRecordReader(private val cursor: Cursor) : Closeable, Iterable<StickerPackRecord> {
|
||||
@@ -882,8 +915,10 @@ class StickerTables(
|
||||
}
|
||||
|
||||
fun asSequence(): Sequence<StickerPackRecord> = sequence {
|
||||
while (getNext() != null) {
|
||||
yield(getCurrent())
|
||||
var record = getNext()
|
||||
while (record != null) {
|
||||
yield(record)
|
||||
record = getNext()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -120,6 +121,16 @@ class StickerSearchDialogFragment : DialogFragment(), KeyboardStickerListAdapter
|
||||
|
||||
override fun onStickerLongClicked(sticker: KeyboardStickerListAdapter.Sticker) = Unit
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
findListener<Callback>()?.onStickerSearchDismissed()
|
||||
}
|
||||
|
||||
/** For a host that has to put something back once this window is gone. */
|
||||
interface Callback {
|
||||
fun onStickerSearchDismissed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
|
||||
@@ -19,7 +19,7 @@ class StickerSearchRepository {
|
||||
@WorkerThread
|
||||
fun search(query: String): List<StickerRecord> {
|
||||
if (query.isEmpty()) {
|
||||
return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).readAll()
|
||||
return StickerRecordReader(stickerTables.getRecentlyUsedStickers(RECENT_LIMIT)).use { reader -> reader.asSequence().toList() }
|
||||
}
|
||||
|
||||
val maybeEmojiQuery: List<StickerRecord> = findStickersForEmoji(query)
|
||||
@@ -36,19 +36,9 @@ class StickerSearchRepository {
|
||||
|
||||
return EmojiUtil.getAllRepresentations(searchEmoji)
|
||||
.filterNotNull()
|
||||
.map { candidate -> StickerRecordReader(stickerTables.getStickersByEmoji(candidate)).readAll() }
|
||||
.map { candidate ->
|
||||
StickerRecordReader(stickerTables.getStickersByEmoji(candidate)).use { reader -> reader.asSequence().toList() }
|
||||
}
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StickerRecordReader.readAll(): List<StickerRecord> {
|
||||
val stickers: MutableList<StickerRecord> = mutableListOf()
|
||||
use { reader ->
|
||||
var record: StickerRecord? = reader.getNext()
|
||||
while (record != null) {
|
||||
stickers.add(record)
|
||||
record = reader.getNext()
|
||||
}
|
||||
}
|
||||
return stickers
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediakeyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.emoji.EmojiSource
|
||||
import org.signal.emoji.EmojiUtil
|
||||
import org.signal.mediakeyboard.data.EmojiCategoryPage
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.KeyboardEmoji
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* [EmojiKeyboardRepository] backed by the app's emoji data: [EmojiSource], the recent emoji store,
|
||||
* and the emoji search index.
|
||||
*/
|
||||
class SignalEmojiKeyboardRepository(
|
||||
private val context: Context,
|
||||
private val recentEmoji: RecentEmojiPageModel
|
||||
) : EmojiKeyboardRepository {
|
||||
|
||||
companion object {
|
||||
private const val EMOJI_SEARCH_LIMIT = 50
|
||||
}
|
||||
|
||||
override suspend fun getEmojiPages(): List<EmojiCategoryPage> {
|
||||
return withContext(Dispatchers.Default) {
|
||||
EmojiSource.latest.displayPages.mapNotNull { page ->
|
||||
val category = EmojiKeyboardCategory.entries.firstOrNull { it.key == page.key } ?: return@mapNotNull null
|
||||
EmojiCategoryPage(
|
||||
category = category,
|
||||
emoji = page.displayEmoji.map { emoji -> KeyboardEmoji(canonical = emoji.value, variations = emoji.variations) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stays on the caller's thread, since [RecentEmojiPageModel] is main-thread-only and shared with the host. */
|
||||
override suspend fun getRecentEmoji(): List<KeyboardEmoji> {
|
||||
return recentEmoji.emoji.map { KeyboardEmoji(canonical = it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately gated only on there being something to search for. The two-character minimum and
|
||||
* the trailing-punctuation rule belong to inline `:word:` replacement, which cannot afford to
|
||||
* guess at every keystroke; an explicit search field can. Blank queries never arrive here, since
|
||||
* those show recents instead.
|
||||
*/
|
||||
override suspend fun search(query: String): List<KeyboardEmoji> {
|
||||
if (query.isBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
val variationsByCanonical = EmojiSource.latest.canonicalToVariations
|
||||
SignalDatabase.emojiSearch.query(query, EMOJI_SEARCH_LIMIT).map { emoji ->
|
||||
KeyboardEmoji(canonical = emoji, variations = variationsByCanonical[emoji] ?: emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPreferredVariations(): Map<String, String> {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val emojiValues = SignalStore.emoji
|
||||
EmojiSource.latest.canonicalToVariations.keys
|
||||
.mapNotNull { canonical ->
|
||||
val preferred = emojiValues.getPreferredVariation(canonical)
|
||||
if (preferred != canonical) canonical to preferred else null
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setPreferredVariation(canonical: String, variation: String) {
|
||||
SignalStore.emoji.setPreferredVariation(variation)
|
||||
}
|
||||
|
||||
override suspend fun onEmojiUsed(emoji: String) {
|
||||
recentEmoji.onCodePointSelected(emoji)
|
||||
}
|
||||
|
||||
override fun getEmojiDrawable(emoji: String): Drawable? {
|
||||
return if (SignalStore.settings.isPreferSystemEmoji) {
|
||||
null
|
||||
} else {
|
||||
EmojiUtil.convertToDrawable(context, emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediakeyboard
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.mediakeyboard.data.GifFetchError
|
||||
import org.signal.mediakeyboard.data.GifKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.GifPage
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl
|
||||
import org.thoughtcrime.securesms.giph.model.GiphyImage
|
||||
import org.thoughtcrime.securesms.giph.model.GiphyResponse
|
||||
import org.thoughtcrime.securesms.net.ContentProxySelector
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* [GifKeyboardRepository] backed by the Giphy API, fetched through the content proxy.
|
||||
*
|
||||
* Fetched [GiphyImage]s are cached by id so that the host can recover the full image metadata
|
||||
* (e.g. the full-size mp4 url) for a selected [KeyboardGif] via [getGiphyImage].
|
||||
*/
|
||||
class SignalGifKeyboardRepository : GifKeyboardRepository {
|
||||
|
||||
companion object {
|
||||
private val BASE_GIPHY_URI: Uri = Uri.parse("https://api.giphy.com/v1/gifs/")
|
||||
.buildUpon()
|
||||
.appendQueryParameter("api_key", BuildConfig.GIPHY_API_KEY)
|
||||
.build()
|
||||
|
||||
private val TRENDING_URI: Uri = BASE_GIPHY_URI.buildUpon().appendPath("trending").build()
|
||||
private val SEARCH_URI: Uri = BASE_GIPHY_URI.buildUpon().appendPath("search").build()
|
||||
|
||||
private const val IMAGE_CACHE_SIZE = 300
|
||||
}
|
||||
|
||||
private val client: OkHttpClient by lazy {
|
||||
AppDependencies.okHttpClient.newBuilder().proxySelector(ContentProxySelector()).build()
|
||||
}
|
||||
|
||||
private val imagesById = object : LinkedHashMap<String, GiphyImage>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, GiphyImage>): Boolean = size > IMAGE_CACHE_SIZE
|
||||
}
|
||||
|
||||
override suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = fetch(query.trim(), offset, limit)
|
||||
val gifs = response.data
|
||||
.filter { !it.gifUrl.isNullOrEmpty() && !it.mp4Url.isNullOrEmpty() && !it.mp4PreviewUrl.isNullOrEmpty() && !it.stillUrl.isNullOrEmpty() }
|
||||
.map { image ->
|
||||
synchronized(imagesById) {
|
||||
imagesById[image.mp4Url] = image
|
||||
}
|
||||
|
||||
KeyboardGif(
|
||||
id = image.mp4Url,
|
||||
still = ChunkedImageUrl(image.stillUrl),
|
||||
mp4PreviewUri = Uri.parse(image.mp4PreviewUrl),
|
||||
width = image.gifWidth,
|
||||
height = image.gifHeight
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(GifPage(gifs = gifs, hasMore = offset + response.data.size < response.pagination.totalCount))
|
||||
} catch (e: IOException) {
|
||||
Result.failure(GifFetchError.Network)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(GifFetchError.Unknown(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGiphyImage(id: String): GiphyImage? {
|
||||
return synchronized(imagesById) {
|
||||
imagesById[id]
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(query: String, offset: Int, limit: Int): GiphyResponse {
|
||||
val url = (if (query.isEmpty()) TRENDING_URI else SEARCH_URI)
|
||||
.buildUpon()
|
||||
.appendQueryParameter("offset", offset.toString())
|
||||
.appendQueryParameter("limit", limit.toString())
|
||||
.apply {
|
||||
if (query.isNotEmpty()) {
|
||||
appendQueryParameter("q", query)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Unexpected code $response")
|
||||
}
|
||||
|
||||
return JsonUtils.fromJson(response.body.byteStream(), GiphyResponse::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediakeyboard
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.mediakeyboard.MediaKeyboardTab
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
/**
|
||||
* [MediaKeyboardRepository] backed by the app's real emoji, sticker, and gif data sources.
|
||||
*/
|
||||
class SignalMediaKeyboardRepository(context: Context, recentEmoji: RecentEmojiPageModel) : MediaKeyboardRepository {
|
||||
|
||||
private val appContext: Context = context.applicationContext
|
||||
|
||||
override val emoji: SignalEmojiKeyboardRepository = SignalEmojiKeyboardRepository(appContext, recentEmoji)
|
||||
override val stickers: SignalStickerKeyboardRepository = SignalStickerKeyboardRepository(appContext)
|
||||
override val gifs: SignalGifKeyboardRepository = SignalGifKeyboardRepository()
|
||||
|
||||
override suspend fun getAvailableTabs(): Set<MediaKeyboardTab> {
|
||||
return setOfNotNull(
|
||||
MediaKeyboardTab.EMOJI,
|
||||
MediaKeyboardTab.STICKER,
|
||||
MediaKeyboardTab.GIF.takeIf { RemoteConfig.gifSearchAvailable }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediakeyboard
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.models.database.StickerRecord
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.signal.mediakeyboard.data.KeyboardSticker
|
||||
import org.signal.mediakeyboard.data.KeyboardStickerPack
|
||||
import org.signal.mediakeyboard.data.StickerKeyboardRepository
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerTables.StickerPackRecordReader
|
||||
import org.thoughtcrime.securesms.database.StickerTables.StickerRecordReader
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
|
||||
/**
|
||||
* [StickerKeyboardRepository] backed by the app's sticker database.
|
||||
*/
|
||||
class SignalStickerKeyboardRepository(private val context: Context) : StickerKeyboardRepository {
|
||||
|
||||
companion object {
|
||||
private const val RECENT_LIMIT = 24
|
||||
}
|
||||
|
||||
override val allowStickerAnimation: Boolean = true
|
||||
|
||||
override fun observeStickerPacks(): Flow<List<KeyboardStickerPack>> {
|
||||
return callbackFlow {
|
||||
val observer = DatabaseObserver.Observer { trySend(Unit) }
|
||||
|
||||
AppDependencies.databaseObserver.registerStickerObserver(observer)
|
||||
AppDependencies.databaseObserver.registerStickerPackObserver(observer)
|
||||
trySend(Unit)
|
||||
|
||||
awaitClose {
|
||||
AppDependencies.databaseObserver.unregisterObserver(observer)
|
||||
}
|
||||
}
|
||||
.conflate()
|
||||
.map { loadStickerPacks() }
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
|
||||
override fun onStickerUsed(sticker: KeyboardSticker) {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
SignalDatabase.stickers.updateStickerLastUsedTime(sticker.packId, sticker.stickerId.toInt(), System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStickerPacks(): List<KeyboardStickerPack> {
|
||||
val stickerTable = SignalDatabase.stickers
|
||||
|
||||
val packRecords = StickerPackRecordReader(stickerTable.getInstalledStickerPacks()).use { reader -> reader.asSequence().toList() }
|
||||
|
||||
val packs = packRecords.map { pack ->
|
||||
val stickers = StickerRecordReader(stickerTable.getStickersForPack(pack.packId)).use { reader ->
|
||||
reader.asSequence().map { it.toKeyboardSticker() }.toList()
|
||||
}
|
||||
|
||||
KeyboardStickerPack(
|
||||
id = pack.packId,
|
||||
title = pack.title.nullIfBlank(),
|
||||
cover = DecryptableUri(pack.cover.uri),
|
||||
stickers = stickers
|
||||
)
|
||||
}
|
||||
|
||||
val recentStickers = StickerRecordReader(stickerTable.getRecentlyUsedStickers(RECENT_LIMIT)).use { reader -> reader.asSequence().toList() }
|
||||
if (recentStickers.isEmpty()) {
|
||||
return packs
|
||||
}
|
||||
|
||||
val recentPack = KeyboardStickerPack(
|
||||
id = StickerKeyboardRepository.RECENT_PACK_ID,
|
||||
title = context.getString(R.string.StickerKeyboard__recently_used),
|
||||
cover = null,
|
||||
stickers = recentStickers.map { it.toKeyboardSticker() }
|
||||
)
|
||||
|
||||
return listOf(recentPack) + packs
|
||||
}
|
||||
|
||||
private fun StickerRecord.toKeyboardSticker(): KeyboardSticker {
|
||||
return KeyboardSticker(
|
||||
packId = packId,
|
||||
packKey = packKey,
|
||||
stickerId = stickerId.toLong(),
|
||||
emoji = emoji.nullIfBlank(),
|
||||
image = DecryptableUri(uri),
|
||||
// Sticker packs only carry static WebP and animated APNG, so a PNG sticker is an APNG.
|
||||
isAnimated = contentType == MediaUtil.IMAGE_PNG
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -289,13 +289,6 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/conversation_input_panel_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="emoji_search_container" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_input_space_left"
|
||||
android:layout_width="0dp"
|
||||
@@ -304,7 +297,7 @@
|
||||
android:paddingStart="5dp"
|
||||
android:paddingEnd="5dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_input_panel_barrier"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:text="160/160 (1)"
|
||||
@@ -320,14 +313,6 @@
|
||||
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/emoji_search_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/polls_create"
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -107,6 +107,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
|
||||
Speaker(icon(R.drawable.symbol_speaker_24)),
|
||||
SpeakerSlash(icon(R.drawable.symbol_speaker_slash_24)),
|
||||
Sticker(icon(R.drawable.symbol_sticker_24)),
|
||||
StickerPack(icon(R.drawable.symbol_stickerpack_24)),
|
||||
Text(icon(R.drawable.symbol_text_24)),
|
||||
TextSquare(icon(R.drawable.symbol_text_square_24)),
|
||||
Transfer(icon(R.drawable.symbol_transfer_24)),
|
||||
|
||||
@@ -3,32 +3,31 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose.mediakeyboard
|
||||
package org.signal.core.ui.compose.keyboard
|
||||
|
||||
/**
|
||||
* What a [MediaKeyboardScaffold] reports back. Requests flow the other way, through
|
||||
* [MediaKeyboardController].
|
||||
* Side effects emitted by a [KeyboardSheetScaffold] that need to be handled by the user of the
|
||||
* component: which of its keyboards is up, and what the system keyboard is doing.
|
||||
*/
|
||||
sealed interface MediaKeyboardEvents {
|
||||
/** @param key The keyboard now showing. */
|
||||
data class KeyboardShown(val key: MediaKeyboardKey) : MediaKeyboardEvents
|
||||
sealed interface KeyboardSheetAction {
|
||||
data class KeyboardShown(val key: KeyboardSheetKey) : KeyboardSheetAction
|
||||
|
||||
/** None of ours is showing any more. */
|
||||
data object KeyboardHidden : MediaKeyboardEvents
|
||||
data object KeyboardHidden : KeyboardSheetAction
|
||||
|
||||
/** One of ours was dismissed by a back gesture rather than by a request. */
|
||||
data object DismissedByBack : MediaKeyboardEvents
|
||||
data object DismissedByBack : KeyboardSheetAction
|
||||
|
||||
/** @param visible Whether the system keyboard is up, on the target state rather than the animated one. */
|
||||
data class SystemKeyboardVisibilityChanged(val visible: Boolean) : MediaKeyboardEvents
|
||||
data class SystemKeyboardVisibilityChanged(val visible: Boolean) : KeyboardSheetAction
|
||||
|
||||
/** The system keyboard finished animating, in either direction. */
|
||||
data object SystemKeyboardAnimationEnded : MediaKeyboardEvents
|
||||
data object SystemKeyboardAnimationEnded : KeyboardSheetAction
|
||||
|
||||
/**
|
||||
* A trustworthy system keyboard height was observed. The scaffold does not persist it.
|
||||
*
|
||||
* @param heightPx The measured height.
|
||||
*/
|
||||
data class SystemKeyboardHeightMeasured(val heightPx: Int) : MediaKeyboardEvents
|
||||
data class SystemKeyboardHeightMeasured(val heightPx: Int) : KeyboardSheetAction
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose.keyboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Asks a [KeyboardSheetScaffold] for a keyboard.
|
||||
*
|
||||
* Stable and safe to hold outside composition, so view code can call into it. Everything flowing the
|
||||
* other way is a [KeyboardSheetAction].
|
||||
*
|
||||
* @param initialKeyboardHeightPx Height to use before one has been measured, typically persisted
|
||||
* from an earlier run.
|
||||
*/
|
||||
@Stable
|
||||
class KeyboardSheetController(initialKeyboardHeightPx: Int = 0) {
|
||||
|
||||
/** The keyboard currently up, or null when none of ours is. */
|
||||
var current: KeyboardSheetKey? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Whether the system keyboard is up, from the target of the IME inset animation. */
|
||||
var isSystemKeyboardVisible: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* True while the system keyboard has been asked for in place of one of ours but has yet to settle.
|
||||
* Holds the space across that gap, which the IME service round trip would otherwise leave empty.
|
||||
*/
|
||||
var awaitingSystemKeyboard: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/** How tall a keyboard of ours should be, replaced whenever a system keyboard is measured. */
|
||||
var keyboardHeightPx: Int by mutableStateOf(initialKeyboardHeightPx)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Whether an expandable keyboard is, or is settling to, full height. Always false for keyboards
|
||||
* that were not declared expandable.
|
||||
*/
|
||||
var isExpanded: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Whether a window of the host's own -- a dialog with a field of its own, say -- is up in front of
|
||||
* the sheet and owns the system keyboard while it is there, so the sheet stays put rather than
|
||||
* giving way to it. Driven by [onHostWindowShown] and [onHostWindowHidden].
|
||||
*/
|
||||
var isBehindHostWindow: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private val _isEnteringText = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Whether the keyboard that is up has taken text entry over, for content with a field of its own
|
||||
* such as a search bar. Text entry cannot outlive the keyboard it is for, and only the scaffold is
|
||||
* in a position to end it -- a drag, a collapse or a dismissal all take the room away -- so it
|
||||
* lives here rather than in the content that asked for it.
|
||||
*/
|
||||
val isEnteringText: StateFlow<Boolean> = _isEnteringText.asStateFlow()
|
||||
|
||||
/** Where [expand] and [collapse] want the sheet; the scaffold drives it there. */
|
||||
internal var expansionTarget: Boolean by mutableStateOf(false)
|
||||
|
||||
val isShowing: Boolean get() = current != null
|
||||
|
||||
fun show(key: KeyboardSheetKey) {
|
||||
reset(key)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
reset(null)
|
||||
}
|
||||
|
||||
/** Grows an expandable keyboard to full height. A no-op for any other keyboard. */
|
||||
fun expand() {
|
||||
expansionTarget = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an expanded keyboard to keyboard height, which also gives up text entry: a field has
|
||||
* nowhere to go once the sheet is no taller than the system keyboard in front of it.
|
||||
*/
|
||||
fun collapse() {
|
||||
_isEnteringText.value = false
|
||||
expansionTarget = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes text entry over on behalf of the keyboard that is up, growing the sheet to leave room for
|
||||
* the system keyboard. Content calls this rather than asking for height, so it cannot end up
|
||||
* holding a field with nowhere to put it.
|
||||
*
|
||||
* Only meaningful for a keyboard declared expandable, and a no-op when none of ours is up.
|
||||
*/
|
||||
fun beginTextEntry() {
|
||||
if (current == null) {
|
||||
return
|
||||
}
|
||||
|
||||
_isEnteringText.value = true
|
||||
expansionTarget = true
|
||||
}
|
||||
|
||||
/** Gives text entry up, taking the system keyboard with it and returning to keyboard height. */
|
||||
fun endTextEntry() {
|
||||
collapse()
|
||||
}
|
||||
|
||||
/** The host has put a window of its own up in front of the sheet. See [isBehindHostWindow]. */
|
||||
fun onHostWindowShown() {
|
||||
isBehindHostWindow = true
|
||||
}
|
||||
|
||||
/** That window is gone, so the system keyboard is the sheet's business again. */
|
||||
fun onHostWindowHidden() {
|
||||
isBehindHostWindow = false
|
||||
}
|
||||
|
||||
/** Closes [key] if it is already up, otherwise swaps to it. */
|
||||
fun toggle(key: KeyboardSheetKey) {
|
||||
if (current == key) hide() else show(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts ours away because the system keyboard is being brought up instead. Only holds the space if
|
||||
* one of ours was up; with nothing to hand over, content should just follow the keyboard in.
|
||||
*
|
||||
* Also the one way out of [isEnteringText] that keeps the system keyboard: the host is taking it
|
||||
* for a field of its own, so it is handed over rather than put away and summoned again.
|
||||
*/
|
||||
fun hideForSystemKeyboard() {
|
||||
reset(null, awaitingSystem = current != null)
|
||||
}
|
||||
|
||||
private fun reset(key: KeyboardSheetKey?, awaitingSystem: Boolean = false) {
|
||||
awaitingSystemKeyboard = awaitingSystem
|
||||
current = key
|
||||
_isEnteringText.value = false
|
||||
expansionTarget = false
|
||||
}
|
||||
}
|
||||
|
||||
/** @param initialKeyboardHeightPx See [KeyboardSheetController]. */
|
||||
@Composable
|
||||
fun rememberKeyboardSheetController(initialKeyboardHeightPx: Int = 0): KeyboardSheetController {
|
||||
return remember { KeyboardSheetController(initialKeyboardHeightPx) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller of the [KeyboardSheetScaffold] a keyboard is being shown by, so its content can
|
||||
* drive the scaffold without the host having to thread callbacks down to it: taking text entry over
|
||||
* for a field of its own, or reading whether it is at full height.
|
||||
*
|
||||
* Only present inside a scaffold's keyboard content.
|
||||
*/
|
||||
val LocalKeyboardSheetController = staticCompositionLocalOf<KeyboardSheetController> {
|
||||
error("Not inside a KeyboardSheetScaffold's keyboard content.")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose.keyboard
|
||||
|
||||
/**
|
||||
* Identifies a keyboard a [KeyboardSheetScaffold] can put up. Callers declare their own, so a screen
|
||||
* only knows about the keyboards it offers.
|
||||
*/
|
||||
@JvmInline
|
||||
value class KeyboardSheetKey(val name: String)
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose.keyboard
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.navigationBarsCompat
|
||||
import org.signal.core.ui.compose.safeDrawingCompat
|
||||
import org.signal.core.ui.compose.systemBarsCompat
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isHeightCompact
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Shapes back gesture progress into travel. Matches the platform's IME hide curve. */
|
||||
private val BACK_TRACKING_EASING = FastOutLinearInEasing
|
||||
|
||||
/** Settles the keyboard once a back gesture lets go. */
|
||||
private val BACK_SETTLE_MOTION = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
/** How long to hold space for a system keyboard that was asked for but never appeared. */
|
||||
private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds
|
||||
|
||||
private val SHEET_POSITIONAL_THRESHOLD = 56.dp
|
||||
private val SHEET_VELOCITY_THRESHOLD = 125.dp
|
||||
|
||||
/** How far an expandable sheet dims what is behind it once at full height. */
|
||||
private const val EXPANDED_SCRIM_ALPHA = 0.32f
|
||||
|
||||
/** How much taller than a keyboard an expandable sheet grows, short of what it is allowed to cover. */
|
||||
private const val EXPANDED_HEIGHT_MULTIPLIER = 2f
|
||||
|
||||
/**
|
||||
* Displays [content] alongside keyboards of our own that stand in for the system keyboard.
|
||||
*
|
||||
* Only one is ever up, and never alongside the system keyboard. The scaffold owns every window inset,
|
||||
* handing [content] an already-inset space to lay out in.
|
||||
*
|
||||
* A keyboard declared expandable can be dragged past keyboard height to fill the window; the
|
||||
* scaffold gives it a drag handle and dims [content] behind it as it grows. [content] keeps the size
|
||||
* it had at keyboard height throughout, so nothing reflows while the sheet moves.
|
||||
*
|
||||
* @param controller Requests which keyboard is up. Stable, so view code may hold one.
|
||||
* @param onAction Receives everything the host may need to act on.
|
||||
* @param keyboardsProvider Declares the available keyboards.
|
||||
* @param keyboardHeight Bounds on how tall a keyboard may be.
|
||||
* @param adjustContentForInput False to let a keyboard cover [content] rather than resize it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun KeyboardSheetScaffold(
|
||||
controller: KeyboardSheetController,
|
||||
onAction: (KeyboardSheetAction) -> Unit,
|
||||
keyboardsProvider: KeyboardSheetScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardHeight: KeyboardSheetHeight = KeyboardSheetHeight(),
|
||||
adjustContentForInput: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val registry = remember(keyboardsProvider) { KeyboardSheetRegistry().apply(keyboardsProvider) }
|
||||
|
||||
val currentOnAction by rememberUpdatedState(onAction)
|
||||
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val imeAnimationTarget = WindowInsets.imeAnimationTarget
|
||||
val windowHeightPx = LocalWindowInfo.current.containerSize.height
|
||||
|
||||
val systemKeyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
val resources = LocalResources.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val isHeightCompact = remember(resources, configuration) { resources.getWindowSizeClass().isHeightCompact }
|
||||
|
||||
val minimumHeightPx = with(density) { keyboardHeight.minimum.roundToPx() }
|
||||
val topMarginPx = with(density) { keyboardHeight.topMargin.roundToPx() }
|
||||
|
||||
val activeKey = controller.current?.takeIf { registry.isEnabled(it) }
|
||||
|
||||
// The target, so a hide reads as gone the moment it is asked for rather than a whole animation later.
|
||||
val systemKeyboardVisible = imeAnimationTarget.getBottom(density) > 0
|
||||
|
||||
// Whether the live inset has yet to catch up with the target. Deliberately not imeAnimationSource,
|
||||
// which the platform leaves behind whenever an animation ends without a duration to run down, and
|
||||
// which would then read as animating until the next keyboard came and went. Derived, so the frames
|
||||
// the live inset walks through are not each a recomposition.
|
||||
val systemKeyboardAnimating by remember(imeInsets, imeAnimationTarget, density) {
|
||||
derivedStateOf { imeInsets.getBottom(density) != imeAnimationTarget.getBottom(density) }
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
controller.isSystemKeyboardVisible = systemKeyboardVisible
|
||||
}
|
||||
|
||||
// The controller outlives us. current is left set so a rebuilt view restores its keyboard.
|
||||
DisposableEffect(controller) {
|
||||
onDispose {
|
||||
controller.isSystemKeyboardVisible = false
|
||||
controller.awaitingSystemKeyboard = false
|
||||
}
|
||||
}
|
||||
|
||||
var hasReportedKeyboardVisibility by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(systemKeyboardVisible) {
|
||||
if (hasReportedKeyboardVisibility) {
|
||||
currentOnAction(KeyboardSheetAction.SystemKeyboardVisibilityChanged(systemKeyboardVisible))
|
||||
}
|
||||
hasReportedKeyboardVisibility = true
|
||||
}
|
||||
|
||||
LaunchedEffect(controller.awaitingSystemKeyboard) {
|
||||
if (controller.awaitingSystemKeyboard) {
|
||||
delay(SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT)
|
||||
controller.awaitingSystemKeyboard = false
|
||||
}
|
||||
}
|
||||
|
||||
// From the animation target, not the live inset, which walks down through every closing frame.
|
||||
LaunchedEffect(imeAnimationTarget, density, minimumHeightPx, isHeightCompact) {
|
||||
if (isHeightCompact) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
snapshotFlow { imeAnimationTarget.getBottom(density) }
|
||||
.filter { it > minimumHeightPx }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
controller.keyboardHeightPx = it
|
||||
currentOnAction(KeyboardSheetAction.SystemKeyboardHeightMeasured(it))
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps ours and the system keyboard from disagreeing in both directions, since the system keyboard
|
||||
// can come and go without the scaffold being told. Read off the inset rather than
|
||||
// systemKeyboardVisible, which is computed in composition and would leave a snapshotFlow emitting
|
||||
// one captured value for good.
|
||||
LaunchedEffect(imeAnimationTarget, density) {
|
||||
snapshotFlow { imeAnimationTarget.getBottom(density) > 0 }
|
||||
.distinctUntilChanged()
|
||||
.dropWhile { visible -> !visible }
|
||||
.collect { visible ->
|
||||
if (visible) {
|
||||
if (controller.current != null && !controller.isEnteringText.value && !controller.isBehindHostWindow) {
|
||||
controller.hideForSystemKeyboard()
|
||||
}
|
||||
} else if (controller.isEnteringText.value) {
|
||||
controller.endTextEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text entry and the system keyboard it needs go away together, from here, because this is also
|
||||
// where the sheet comes back down. Leaving it to the content's field would let the IME service take
|
||||
// its own time noticing nothing is focused, trailing the keyboard out a beat behind the height.
|
||||
LaunchedEffect(controller, systemKeyboard) {
|
||||
controller.isEnteringText
|
||||
.dropWhile { entering -> !entering }
|
||||
.filter { entering -> !entering }
|
||||
.collect {
|
||||
// Unless the host is taking the keyboard over for a field of its own, which is the one way
|
||||
// out of text entry that wants to keep it. [KeyboardSheetController.hideForSystemKeyboard]
|
||||
// sets both in the one call, so this reads the same state the emission was made from.
|
||||
if (!controller.awaitingSystemKeyboard) {
|
||||
systemKeyboard?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropWhile rather than drop, so a composition that starts mid-animation still reports that settle.
|
||||
LaunchedEffect(imeInsets, imeAnimationTarget, density) {
|
||||
snapshotFlow { imeInsets.getBottom(density) == imeAnimationTarget.getBottom(density) }
|
||||
.distinctUntilChanged()
|
||||
.dropWhile { settled -> settled }
|
||||
.filter { settled -> settled }
|
||||
.collect {
|
||||
controller.awaitingSystemKeyboard = false
|
||||
currentOnAction(KeyboardSheetAction.SystemKeyboardAnimationEnded)
|
||||
}
|
||||
}
|
||||
|
||||
val heightPx = keyboardHeight.resolve(
|
||||
preferredPx = controller.keyboardHeightPx,
|
||||
windowHeightPx = windowHeightPx,
|
||||
minimumPx = minimumHeightPx,
|
||||
topMarginPx = topMarginPx
|
||||
)
|
||||
val height = with(density) { heightPx.toDp() }
|
||||
|
||||
val backProgress = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Read through rememberUpdatedState rather than captured: these lambdas outlive the composition
|
||||
// that built the sheet, and keying the remember on density instead would rebuild the sheet at its
|
||||
// initial value, putting a keyboard away because the font scale changed.
|
||||
val currentDensity by rememberUpdatedState(density)
|
||||
val sheetState = remember {
|
||||
SheetState(
|
||||
skipPartiallyExpanded = false,
|
||||
positionalThreshold = { with(currentDensity) { SHEET_POSITIONAL_THRESHOLD.toPx() } },
|
||||
velocityThreshold = { with(currentDensity) { SHEET_VELOCITY_THRESHOLD.toPx() } },
|
||||
initialValue = SheetValue.Hidden,
|
||||
skipHiddenState = false
|
||||
)
|
||||
}
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState)
|
||||
|
||||
// This just makes sure the previously visible state doesn't go away too early while we're mid swap.
|
||||
var visibleKey by remember { mutableStateOf<KeyboardSheetKey?>(null) }
|
||||
|
||||
// Set while one of ours is coming up in place of the system keyboard. The sheet's rise is not that
|
||||
// keyboard's fall, so the space is held flat for the crossing rather than tracking either curve.
|
||||
var handingOverFromSystemKeyboard by remember { mutableStateOf(false) }
|
||||
|
||||
val expandable = registry.isExpandable(visibleKey)
|
||||
|
||||
// BottomSheetScaffold measures its anchors from the sheet, so the sheet is always as tall as it
|
||||
// could ever need to be and the part below the fold simply hangs off the bottom of the window.
|
||||
var scaffoldHeightPx by remember { mutableIntStateOf(0) }
|
||||
val layoutHeightPx = if (scaffoldHeightPx > 0) scaffoldHeightPx else windowHeightPx
|
||||
val minimumContentPx = with(density) { keyboardHeight.minimumContentVisible.roundToPx() }
|
||||
val windowLimitPx = (layoutHeightPx - WindowInsets.statusBars.getTop(density) - minimumContentPx).coerceAtLeast(heightPx)
|
||||
val expandedHeightPx = (heightPx * EXPANDED_HEIGHT_MULTIPLIER).roundToInt().coerceIn(heightPx, windowLimitPx)
|
||||
val sheetHeightPx = if (expandable) expandedHeightPx else heightPx
|
||||
val sheetHeight = with(density) { sheetHeightPx.toDp() }
|
||||
|
||||
/**
|
||||
* How much of the sheet the window actually shows. Reads the live sheet offset, so anything that
|
||||
* calls it from layout or draw follows a drag frame by frame.
|
||||
*/
|
||||
val visibleSheetHeightPx = {
|
||||
val offset = try {
|
||||
// Throws until the sheet has been measured for the first time.
|
||||
sheetState.requireOffset()
|
||||
} catch (ignored: IllegalStateException) {
|
||||
null
|
||||
}
|
||||
|
||||
if (offset == null) {
|
||||
if (visibleKey != null) heightPx else 0
|
||||
} else {
|
||||
(layoutHeightPx - offset).roundToInt().coerceIn(0, sheetHeightPx)
|
||||
}
|
||||
}
|
||||
|
||||
/** 0 at keyboard height, 1 at full height. */
|
||||
val expansionFraction = {
|
||||
val travelPx = sheetHeightPx - heightPx
|
||||
if (!expandable || travelPx <= 0) 0f else ((visibleSheetHeightPx() - heightPx).toFloat() / travelPx).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sheet belongs, from what has been asked for rather than from where it happens to be.
|
||||
* Every request folds into this one value: a keyboard shown, swapped or put away, and an expansion
|
||||
* asked for or given up.
|
||||
*/
|
||||
val sheetTarget = when {
|
||||
activeKey == null -> SheetValue.Hidden
|
||||
expandable && controller.expansionTarget -> SheetValue.Expanded
|
||||
else -> SheetValue.PartiallyExpanded
|
||||
}
|
||||
|
||||
// The one place the sheet is driven from. Keyed on the target, so every change comes through here
|
||||
// in order and a new one cancels its own predecessor rather than queueing behind it.
|
||||
LaunchedEffect(sheetTarget, activeKey) {
|
||||
val key = activeKey
|
||||
|
||||
if (key == null) {
|
||||
if (visibleKey == null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// A gesture may already have carried it off screen; hold that until the hide completes.
|
||||
handingOverFromSystemKeyboard = false
|
||||
sheetState.hide()
|
||||
backProgress.snapTo(0f)
|
||||
visibleKey = null
|
||||
currentOnAction(KeyboardSheetAction.KeyboardHidden)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val arriving = visibleKey != key
|
||||
if (arriving) {
|
||||
if (visibleKey != null) {
|
||||
currentOnAction(KeyboardSheetAction.KeyboardHidden)
|
||||
}
|
||||
|
||||
handingOverFromSystemKeyboard = systemKeyboardVisible || systemKeyboardAnimating
|
||||
visibleKey = key
|
||||
backProgress.snapTo(0f)
|
||||
}
|
||||
|
||||
when {
|
||||
sheetTarget == SheetValue.Expanded && sheetState.hasExpandedState -> sheetState.expand()
|
||||
sheetState.currentValue == SheetValue.Hidden -> sheetState.show()
|
||||
else -> sheetState.partialExpand()
|
||||
}
|
||||
|
||||
if (arriving) {
|
||||
handingOverFromSystemKeyboard = false
|
||||
currentOnAction(KeyboardSheetAction.KeyboardShown(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Picks up drags as well as requests, so the controller reports where the sheet actually went.
|
||||
LaunchedEffect(sheetState) {
|
||||
snapshotFlow { sheetState.targetValue == SheetValue.Expanded }
|
||||
.distinctUntilChanged()
|
||||
.collect { expanded ->
|
||||
controller.isExpanded = expanded
|
||||
|
||||
if (expanded) {
|
||||
controller.expansionTarget = true
|
||||
} else {
|
||||
controller.endTextEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A swipe can carry the sheet away without anyone having asked it to, leaving the controller
|
||||
// believing a keyboard is still up and content still holding space for one.
|
||||
LaunchedEffect(sheetState) {
|
||||
snapshotFlow { sheetState.currentValue }
|
||||
.filter { it == SheetValue.Hidden }
|
||||
.collect {
|
||||
if (controller.isShowing) {
|
||||
controller.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(enabled = activeKey != null) { progress ->
|
||||
try {
|
||||
progress.collect { backEvent -> backProgress.snapTo(BACK_TRACKING_EASING.transform(backEvent.progress)) }
|
||||
|
||||
backProgress.animateTo(1f, BACK_SETTLE_MOTION)
|
||||
controller.hide()
|
||||
onAction(KeyboardSheetAction.DismissedByBack)
|
||||
} catch (cancelled: CancellationException) {
|
||||
// PredictiveBackHandler cancels this job, so the unwind must run somewhere that outlives it.
|
||||
scope.launch { backProgress.animateTo(0f, BACK_SETTLE_MOTION) }
|
||||
throw cancelled
|
||||
}
|
||||
}
|
||||
|
||||
val systemKeyboardTakingOverSpace = activeKey == null &&
|
||||
(controller.awaitingSystemKeyboard || (systemKeyboardVisible && systemKeyboardAnimating))
|
||||
|
||||
val claimedBottomPx = {
|
||||
if (systemKeyboardTakingOverSpace || handingOverFromSystemKeyboard) {
|
||||
heightPx
|
||||
} else if (activeKey != null || visibleKey != null) {
|
||||
// Tracks the sheet rather than its resting height, so content rides along with a drag.
|
||||
(visibleSheetHeightPx() * (1f - backProgress.value)).roundToInt().coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
var ancestorConsumedBottomPx by remember { mutableIntStateOf(0) }
|
||||
val safeDrawingInsets = WindowInsets.safeDrawingCompat
|
||||
|
||||
val windowInsets = if (adjustContentForInput) {
|
||||
safeDrawingInsets
|
||||
} else {
|
||||
WindowInsets.systemBarsCompat.add(WindowInsets.displayCutout)
|
||||
}
|
||||
|
||||
val containerColor = registry.containerColorFor(visibleKey).takeOrElse { MaterialTheme.colorScheme.surfaceContainerLow }
|
||||
val scrimColor = MaterialTheme.colorScheme.scrim
|
||||
val sheetShape = if (expandable) {
|
||||
MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
|
||||
} else {
|
||||
RectangleShape
|
||||
}
|
||||
// Keyed, because expansionFraction closes over this composition's heights and visible key; an
|
||||
// unkeyed remember would hold the first composition's, from before anything was up.
|
||||
val scrimShowing by remember(expandable, heightPx, sheetHeightPx, layoutHeightPx, visibleKey) {
|
||||
derivedStateOf { expansionFraction() > 0f }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { scaffoldHeightPx = it.height }
|
||||
) {
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = height,
|
||||
sheetShape = sheetShape,
|
||||
sheetDragHandle = null,
|
||||
sheetSwipeEnabled = expandable,
|
||||
sheetContainerColor = Color.Transparent,
|
||||
sheetTonalElevation = 0.dp,
|
||||
sheetShadowElevation = 0.dp,
|
||||
containerColor = Color.Transparent,
|
||||
sheetContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sheetHeight)
|
||||
// Off the bottom by whatever is actually on screen, which an expanded sheet outgrows.
|
||||
.graphicsLayer { translationY = backProgress.value * visibleSheetHeightPx() }
|
||||
.background(containerColor)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layout { measurable, constraints ->
|
||||
// Only the part of the sheet the window shows is worth laying content out in, and
|
||||
// never less than a keyboard: below that the sheet is on its way out, not resizing.
|
||||
val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight)
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
|
||||
) {
|
||||
if (expandable) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
CompositionLocalProvider(LocalKeyboardSheetController provides controller) {
|
||||
registry.contentFor(visibleKey)?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { _ ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) }
|
||||
.windowInsetsPadding(windowInsets)
|
||||
.layout { measurable, constraints ->
|
||||
// Window insets are already out of these constraints; take only the excess claim.
|
||||
val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0)
|
||||
val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0
|
||||
|
||||
val available = (constraints.maxHeight - extraPx).coerceAtLeast(0)
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (expandable) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind { drawRect(color = scrimColor, alpha = EXPANDED_SCRIM_ALPHA * expansionFraction()) }
|
||||
.then(
|
||||
if (scrimShowing) {
|
||||
// Collapses rather than dismisses: the dim means the sheet has grown past keyboard
|
||||
// height, and keyboard height is where it belongs the rest of the time, so undoing
|
||||
// the growth is what the gesture is for. Dismissing would also be indiscriminate,
|
||||
// since this covers the whole of [content] -- a host's own text field included.
|
||||
Modifier.pointerInput(Unit) { detectTapGestures { controller.collapse() } }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds on how tall a keyboard may be. The height it wants comes from
|
||||
* [KeyboardSheetController.keyboardHeightPx].
|
||||
*
|
||||
* Holds a lambda, so callers should [remember] it or the scaffold cannot skip recomposition.
|
||||
*
|
||||
* @param minimum Floor, used before any keyboard has been measured.
|
||||
* @param topMargin Kept clear of the top of the window, capping the height.
|
||||
* @param minimumContentVisible How much of the content an expanded keyboard must leave in view,
|
||||
* capping how far it can grow. Only the host can know what this should be, since it covers
|
||||
* whatever chrome the content puts between its edges and the part worth keeping visible.
|
||||
* @param overrideForWindow Derives a height from the window instead, for windows unlike the one a
|
||||
* keyboard was measured in.
|
||||
*/
|
||||
@Immutable
|
||||
data class KeyboardSheetHeight(
|
||||
val minimum: Dp = 260.dp,
|
||||
val topMargin: Dp = 170.dp,
|
||||
val minimumContentVisible: Dp = 0.dp,
|
||||
val overrideForWindow: ((windowHeightPx: Int) -> Int)? = null
|
||||
) {
|
||||
internal fun resolve(preferredPx: Int, windowHeightPx: Int, minimumPx: Int, topMarginPx: Int): Int {
|
||||
if (windowHeightPx <= 0) {
|
||||
return maxOf(preferredPx, minimumPx)
|
||||
}
|
||||
|
||||
val maximumPx = (windowHeightPx - topMarginPx).coerceAtLeast(minimumPx)
|
||||
|
||||
overrideForWindow?.let { return it(windowHeightPx).coerceAtMost(maximumPx) }
|
||||
|
||||
return preferredPx.coerceIn(minimumPx, maximumPx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose.keyboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** Declares which keyboards a [KeyboardSheetScaffold] offers. */
|
||||
interface KeyboardSheetScope {
|
||||
/**
|
||||
* Offers the keyboard identified by [key].
|
||||
*
|
||||
* @param enabled False to keep the key known but refuse requests for it.
|
||||
* @param containerColor Fills the sheet, navigation bar included, so it should match whatever
|
||||
* [content] paints its edges with. Unspecified falls back to the scaffold's surface.
|
||||
* @param expandable True to let this keyboard grow past keyboard height to fill the window. Gives
|
||||
* the sheet a drag handle, lets it be swiped, and dims what is behind it as it grows.
|
||||
* @param content The keyboard itself.
|
||||
*/
|
||||
fun keyboard(
|
||||
key: KeyboardSheetKey,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = Color.Unspecified,
|
||||
expandable: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
internal class KeyboardSheetRegistry : KeyboardSheetScope {
|
||||
private val entries = LinkedHashMap<KeyboardSheetKey, Entry>()
|
||||
|
||||
override fun keyboard(
|
||||
key: KeyboardSheetKey,
|
||||
enabled: Boolean,
|
||||
containerColor: Color,
|
||||
expandable: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
entries[key] = Entry(enabled, containerColor, expandable, content)
|
||||
}
|
||||
|
||||
fun isEnabled(key: KeyboardSheetKey?): Boolean = key != null && entries[key]?.enabled == true
|
||||
|
||||
fun contentFor(key: KeyboardSheetKey?): (@Composable () -> Unit)? {
|
||||
return enabledEntry(key)?.content
|
||||
}
|
||||
|
||||
fun containerColorFor(key: KeyboardSheetKey?): Color {
|
||||
return enabledEntry(key)?.containerColor ?: Color.Unspecified
|
||||
}
|
||||
|
||||
fun isExpandable(key: KeyboardSheetKey?): Boolean = enabledEntry(key)?.expandable == true
|
||||
|
||||
private fun enabledEntry(key: KeyboardSheetKey?): Entry? {
|
||||
return key?.let { entries[it] }?.takeIf { it.enabled }
|
||||
}
|
||||
|
||||
private class Entry(
|
||||
val enabled: Boolean,
|
||||
val containerColor: Color,
|
||||
val expandable: Boolean,
|
||||
val content: @Composable () -> Unit
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M14.75 22.4c2.85 0 8.65-5.8 8.65-8.65 0-4.08-2.82-7.5-6.63-8.41-0.59-3.07-2.76-4.58-5.98-4.16l-5.45 0.71C1.95 2.33 0.24 4.55 0.68 7.95l0.72 5.46c0.44 3.33 2.58 5.02 5.85 4.67a8.66 8.66 0 0 0 7.49 4.32ZM3.19 13.17l-0.72-5.46c-0.31-2.4 0.7-3.73 3.1-4.04l5.45-0.71c2.06-0.27 3.32 0.43 3.84 2.14h-0.12a8.646 8.646 0 0 0-8.26 11.22c-1.94 0.02-3.02-1.01-3.3-3.15Zm4.71 0.58c0-3.78 3.07-6.85 6.85-6.85 3.59 0 6.54 2.77 6.83 6.29a6.419 6.419 0 0 0-7.39 7.39c-3.52-0.29-6.29-3.24-6.29-6.83m8 5.78c0-2.56 2.07-4.63 4.63-4.63 0.22 0 0.44 0.02 0.66 0.05-0.98 1.75-3.49 4.27-5.24 5.24-0.03-0.21-0.05-0.43-0.05-0.66m0 0"/>
|
||||
</vector>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".MediaKeyboardDemoApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MediaKeyboardDemo">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediakeyboard.demo.main.MainScreen
|
||||
import org.signal.mediakeyboard.demo.main.MainScreenViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MainScreenViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val repository = (application as MediaKeyboardDemoApplication).repository
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
repository = repository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import org.signal.core.ui.CoreUiDependencies
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediakeyboard.demo.data.DemoMediaKeyboardRepository
|
||||
import org.thoughtcrime.securesms.mms.RegisterGlideComponents
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule
|
||||
|
||||
class MediaKeyboardDemoApplication : Application() {
|
||||
|
||||
lateinit var repository: DemoMediaKeyboardRepository
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Log.initialize(AndroidLogger)
|
||||
|
||||
SignalGlideModule.registerGlideComponents = object : RegisterGlideComponents {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) = Unit
|
||||
}
|
||||
|
||||
CoreUiDependencies.init(
|
||||
this,
|
||||
object : CoreUiDependencies.Provider {
|
||||
override fun providePackageId(): String = BuildConfig.APPLICATION_ID
|
||||
override fun provideIsIncognitoKeyboardEnabled(): Boolean = false
|
||||
override fun provideIsScreenSecurityEnabled(): Boolean = false
|
||||
}
|
||||
)
|
||||
|
||||
repository = DemoMediaKeyboardRepository(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.data
|
||||
|
||||
import org.signal.mediakeyboard.data.EmojiCategoryPage
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.KeyboardEmoji
|
||||
|
||||
/**
|
||||
* An in-memory emoji source with a small but representative data set, including skin tone
|
||||
* variations and a keyword search index.
|
||||
*/
|
||||
class DemoEmojiKeyboardRepository : EmojiKeyboardRepository {
|
||||
|
||||
private val skinTones = listOf("🏻", "🏼", "🏽", "🏾", "🏿")
|
||||
|
||||
private fun toned(base: String): KeyboardEmoji {
|
||||
return KeyboardEmoji(canonical = base, variations = listOf(base) + skinTones.map { base + it })
|
||||
}
|
||||
|
||||
private fun plain(emoji: String): KeyboardEmoji = KeyboardEmoji(emoji)
|
||||
|
||||
private val pages: List<EmojiCategoryPage> = listOf(
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.PEOPLE,
|
||||
emoji = listOf(
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩",
|
||||
"😘", "😋", "😛", "😜", "🤪", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😶", "😏", "😒",
|
||||
"🙄", "😬", "😌", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤢", "🥵", "🥶", "🥴", "🤯", "🥳", "😎",
|
||||
"🤓", "🧐", "😕", "😟", "🙁", "😮", "😯", "😲", "😳", "🥺", "😨", "😰", "😥", "😢", "😭", "😱",
|
||||
"😖", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬"
|
||||
).map(::plain) + listOf(
|
||||
toned("👍"), toned("👎"), toned("👋"), toned("🙏"), toned("👏"), toned("💪"), toned("🤞"),
|
||||
toned("🤘"), toned("👌"), toned("👈"), toned("👉"), toned("👆"), toned("👇"), toned("✋"),
|
||||
toned("🤚"), toned("👊"), toned("✊"), toned("🤛"), toned("🤜")
|
||||
)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.NATURE,
|
||||
emoji = listOf(
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
|
||||
"🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🐺", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐢", "🐍",
|
||||
"🐙", "🦀", "🐠", "🐟", "🐬", "🐳", "🦈", "🔥", "🌸", "💐", "🌹", "🌺", "🌻", "🌼", "🌷", "🌱",
|
||||
"🌲", "🌳", "🌴", "🌵", "🍀", "🌈", "⭐", "🌙", "☀️", "⛅", "❄️", "💧"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.FOODS,
|
||||
emoji = listOf(
|
||||
"🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅",
|
||||
"🍆", "🥑", "🥦", "🥒", "🌶️", "🌽", "🥕", "🥔", "🥐", "🍞", "🥖", "🥨", "🧀", "🍳", "🥞", "🧇",
|
||||
"🥓", "🍗", "🌭", "🍔", "🍟", "🍕", "🥪", "🌮", "🌯", "🥗", "🍝", "🍜", "🍣", "🍱", "🍤", "🍚",
|
||||
"🍦", "🍧", "🍨", "🍩", "🍪", "🎂", "🍰", "🧁", "🍫", "🍬", "🍭", "☕", "🍵", "🥤", "🍺", "🍷"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.ACTIVITY,
|
||||
emoji = listOf(
|
||||
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🏒", "🥅", "⛳", "🏹", "🎣",
|
||||
"🥊", "🥋", "🛹", "⛸️", "🎿", "🎪", "🎉", "🎊", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸",
|
||||
"🎻", "🎲", "🎯", "🎳", "🎮", "🧩"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.PLACES,
|
||||
emoji = listOf(
|
||||
"🚗", "🚕", "🚙", "🚌", "🚓", "🚑", "🚒", "🚚", "🚜", "🛴", "🚲", "🛵", "🚨", "🚄", "🚅", "🚂",
|
||||
"✈️", "🛫", "🚀", "🛸", "🚁", "⛵", "🚤", "🚢", "⚓", "🗼", "🏰", "🎡", "🎢", "🎠", "⛲", "🏖️",
|
||||
"🌋", "🗽", "🏠", "🏢", "🏥", "🏦", "🏫", "⛪"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.OBJECTS,
|
||||
emoji = listOf(
|
||||
"⌚", "📱", "💻", "⌨️", "🖥️", "🖨️", "📷", "📸", "📹", "🎥", "📞", "☎️", "📺", "📻", "⏰", "⌛",
|
||||
"🔋", "🔌", "💡", "🔦", "🕯️", "💸", "💵", "💰", "💳", "💎", "🔧", "🔨", "🛠️", "⚙️", "🔮", "🔭",
|
||||
"🔬", "💊", "💉", "🎁", "🎈", "🎀", "📚", "📖", "✏️", "📌"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.SYMBOLS,
|
||||
emoji = listOf(
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖",
|
||||
"💘", "💝", "☮️", "☯️", "✅", "❌", "❓", "❗", "💯", "♻️", "💤", "🆒", "🆕", "🆓", "🔴", "🟠",
|
||||
"🟡", "🟢", "🔵", "🟣", "⚫", "⚪", "💬", "💭"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.FLAGS,
|
||||
emoji = listOf(
|
||||
"🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️🌈", "🏴☠️", "🇺🇸", "🇨🇦", "🇬🇧", "🇫🇷", "🇩🇪", "🇮🇹", "🇪🇸", "🇵🇹", "🇧🇷",
|
||||
"🇲🇽", "🇯🇵", "🇰🇷", "🇨🇳", "🇮🇳", "🇦🇺", "🇳🇿", "🇿🇦", "🇳🇬", "🇪🇬", "🇸🇪", "🇳🇴", "🇩🇰", "🇫🇮", "🇺🇦", "🇵🇱"
|
||||
).map(::plain)
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.EMOTICONS,
|
||||
emoji = listOf(
|
||||
":-)", ";-)", ":-(", ":-P", ":-D", ":-O", ":-*", "<3", ":-/", ":-|", "^_^", "T_T"
|
||||
).map(::plain)
|
||||
)
|
||||
)
|
||||
|
||||
private val searchIndex: Map<String, List<String>> = mapOf(
|
||||
"smile" to listOf("😀", "😃", "😄", "🙂", "😊"),
|
||||
"laugh" to listOf("😂", "🤣", "😆"),
|
||||
"happy" to listOf("😀", "😃", "😄", "😁", "🥳"),
|
||||
"sad" to listOf("😢", "😭", "🙁", "😞", "💔"),
|
||||
"cry" to listOf("😢", "😭"),
|
||||
"angry" to listOf("😡", "😠", "🤬"),
|
||||
"love" to listOf("❤️", "😍", "🥰", "😘", "💕", "💖"),
|
||||
"heart" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "💔", "💕", "💖"),
|
||||
"kiss" to listOf("😘", ":-*"),
|
||||
"wink" to listOf("😉", ";-)"),
|
||||
"cool" to listOf("😎", "🆒"),
|
||||
"party" to listOf("🥳", "🎉", "🎊", "🎈"),
|
||||
"celebrate" to listOf("🎉", "🎊", "🥳", "🍾"),
|
||||
"thumbs up" to listOf("👍"),
|
||||
"thumbs down" to listOf("👎"),
|
||||
"wave" to listOf("👋"),
|
||||
"clap" to listOf("👏"),
|
||||
"pray" to listOf("🙏"),
|
||||
"strong" to listOf("💪"),
|
||||
"fire" to listOf("🔥"),
|
||||
"dog" to listOf("🐶"),
|
||||
"cat" to listOf("🐱"),
|
||||
"fox" to listOf("🦊"),
|
||||
"unicorn" to listOf("🦄"),
|
||||
"flower" to listOf("🌸", "🌹", "🌺", "🌻", "🌷", "💐"),
|
||||
"star" to listOf("⭐", "🤩"),
|
||||
"sun" to listOf("☀️"),
|
||||
"moon" to listOf("🌙"),
|
||||
"rain" to listOf("💧", "🌈"),
|
||||
"snow" to listOf("❄️"),
|
||||
"pizza" to listOf("🍕"),
|
||||
"burger" to listOf("🍔"),
|
||||
"cake" to listOf("🎂", "🍰", "🧁"),
|
||||
"coffee" to listOf("☕"),
|
||||
"beer" to listOf("🍺"),
|
||||
"car" to listOf("🚗", "🚕", "🚙"),
|
||||
"plane" to listOf("✈️", "🛫"),
|
||||
"rocket" to listOf("🚀"),
|
||||
"music" to listOf("🎤", "🎧", "🎼", "🎸"),
|
||||
"game" to listOf("🎮", "🎲"),
|
||||
"ball" to listOf("⚽", "🏀", "🏈", "⚾", "🎾"),
|
||||
"money" to listOf("💸", "💵", "💰", "🤑"),
|
||||
"phone" to listOf("📱", "📞", "☎️"),
|
||||
"check" to listOf("✅"),
|
||||
"hundred" to listOf("💯"),
|
||||
"sleep" to listOf("😴", "💤"),
|
||||
"flag" to listOf("🏁", "🚩", "🏳️", "🏴")
|
||||
)
|
||||
|
||||
private val emojiByCanonical: Map<String, KeyboardEmoji> = pages
|
||||
.flatMap { it.emoji }
|
||||
.associateBy { it.canonical }
|
||||
|
||||
private val recents = ArrayDeque(listOf("😂", "❤️", "👍", "😍", "🔥", "🎉", "😭", "🙏"))
|
||||
private val preferredVariations = mutableMapOf<String, String>()
|
||||
|
||||
override suspend fun getEmojiPages(): List<EmojiCategoryPage> = pages
|
||||
|
||||
override suspend fun getRecentEmoji(): List<KeyboardEmoji> {
|
||||
return recents.map { emojiByCanonical[it] ?: KeyboardEmoji(it) }
|
||||
}
|
||||
|
||||
override suspend fun search(query: String): List<KeyboardEmoji> {
|
||||
val normalized = query.trim().lowercase()
|
||||
if (normalized.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return searchIndex
|
||||
.filterKeys { it.contains(normalized) }
|
||||
.values
|
||||
.flatten()
|
||||
.distinct()
|
||||
.map { emojiByCanonical[it] ?: KeyboardEmoji(it) }
|
||||
}
|
||||
|
||||
override suspend fun getPreferredVariations(): Map<String, String> = preferredVariations.toMap()
|
||||
|
||||
override fun setPreferredVariation(canonical: String, variation: String) {
|
||||
if (canonical == variation) {
|
||||
preferredVariations.remove(canonical)
|
||||
} else {
|
||||
preferredVariations[canonical] = variation
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onEmojiUsed(emoji: String) {
|
||||
recents.remove(emoji)
|
||||
recents.addFirst(emoji)
|
||||
while (recents.size > MAX_RECENTS) {
|
||||
recents.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_RECENTS = 24
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.RawRes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.mediakeyboard.data.GifFetchError
|
||||
import org.signal.mediakeyboard.data.GifKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.GifPage
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.signal.mediakeyboard.demo.R
|
||||
|
||||
/**
|
||||
* A fake gif source backed by bundled mp4 clips. Simulates network latency and pagination.
|
||||
* Search for "error" to see the failure state, or "empty" to see the no-results state.
|
||||
*/
|
||||
class DemoGifKeyboardRepository(private val context: Context) : GifKeyboardRepository {
|
||||
|
||||
private data class Template(
|
||||
@field:RawRes val video: Int,
|
||||
@field:DrawableRes val still: Int,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
|
||||
private val templates = listOf(
|
||||
Template(R.raw.demo_gif_1, R.drawable.demo_gif_still_1, 240, 240),
|
||||
Template(R.raw.demo_gif_2, R.drawable.demo_gif_still_2, 320, 240),
|
||||
Template(R.raw.demo_gif_3, R.drawable.demo_gif_still_3, 240, 320),
|
||||
Template(R.raw.demo_gif_4, R.drawable.demo_gif_still_4, 320, 180),
|
||||
Template(R.raw.demo_gif_5, R.drawable.demo_gif_still_5, 180, 320),
|
||||
Template(R.raw.demo_gif_6, R.drawable.demo_gif_still_6, 240, 180)
|
||||
)
|
||||
|
||||
override suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError> {
|
||||
delay(600)
|
||||
|
||||
val normalized = query.trim().lowercase()
|
||||
|
||||
if (normalized == "error") {
|
||||
return Result.failure(GifFetchError.Network)
|
||||
}
|
||||
|
||||
if (normalized == "empty") {
|
||||
return Result.success(GifPage(gifs = emptyList(), hasMore = false))
|
||||
}
|
||||
|
||||
val total = if (normalized.isEmpty()) 60 else 40
|
||||
if (offset >= total) {
|
||||
return Result.success(GifPage(gifs = emptyList(), hasMore = false))
|
||||
}
|
||||
|
||||
val count = minOf(limit, total - offset)
|
||||
val seed = normalized.hashCode()
|
||||
|
||||
val gifs = (0 until count).map { i ->
|
||||
val template = templates[(seed + offset + i).mod(templates.size)]
|
||||
KeyboardGif(
|
||||
id = "$normalized:${offset + i}",
|
||||
still = template.still,
|
||||
mp4PreviewUri = Uri.parse("android.resource://${context.packageName}/${template.video}"),
|
||||
width = template.width,
|
||||
height = template.height
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success(GifPage(gifs = gifs, hasMore = offset + count < total))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.data
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.GifKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
import org.signal.mediakeyboard.data.StickerKeyboardRepository
|
||||
|
||||
class DemoMediaKeyboardRepository(context: Context) : MediaKeyboardRepository {
|
||||
override val emoji: EmojiKeyboardRepository = DemoEmojiKeyboardRepository()
|
||||
override val stickers: StickerKeyboardRepository = DemoStickerKeyboardRepository(context)
|
||||
override val gifs: GifKeyboardRepository = DemoGifKeyboardRepository(context)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import androidx.annotation.DrawableRes
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.signal.mediakeyboard.data.KeyboardSticker
|
||||
import org.signal.mediakeyboard.data.KeyboardStickerPack
|
||||
import org.signal.mediakeyboard.data.StickerKeyboardRepository
|
||||
import org.signal.mediakeyboard.demo.R
|
||||
|
||||
/**
|
||||
* An in-memory sticker source backed by bundled vector drawables. The vectors are rasterized to
|
||||
* bitmaps because Glide has no disk-cache encoder for VectorDrawable.
|
||||
*/
|
||||
class DemoStickerKeyboardRepository(private val context: Context) : StickerKeyboardRepository {
|
||||
|
||||
private data class DemoSticker(
|
||||
val name: String,
|
||||
@field:DrawableRes val image: Int,
|
||||
val emoji: String,
|
||||
val packId: String,
|
||||
val id: Long
|
||||
)
|
||||
|
||||
private val demoStickers: List<DemoSticker> = buildList {
|
||||
var id = 0L
|
||||
fun sticker(name: String, image: Int, emoji: String, packId: String) {
|
||||
add(DemoSticker(name, image, emoji, packId, id++))
|
||||
}
|
||||
|
||||
sticker("heart", R.drawable.sticker_heart, "❤️", SHAPES_PACK_ID)
|
||||
sticker("star", R.drawable.sticker_star, "⭐", SHAPES_PACK_ID)
|
||||
sticker("circle", R.drawable.sticker_circle, "🔵", SHAPES_PACK_ID)
|
||||
sticker("triangle", R.drawable.sticker_triangle, "🟢", SHAPES_PACK_ID)
|
||||
sticker("diamond", R.drawable.sticker_diamond, "💎", SHAPES_PACK_ID)
|
||||
sticker("moon", R.drawable.sticker_moon, "🌙", SHAPES_PACK_ID)
|
||||
sticker("sun", R.drawable.sticker_sun, "☀️", SHAPES_PACK_ID)
|
||||
sticker("bolt", R.drawable.sticker_bolt, "⚡", SHAPES_PACK_ID)
|
||||
|
||||
sticker("happy", R.drawable.sticker_face_happy, "😀", FACES_PACK_ID)
|
||||
sticker("sad", R.drawable.sticker_face_sad, "😢", FACES_PACK_ID)
|
||||
sticker("wink", R.drawable.sticker_face_wink, "😉", FACES_PACK_ID)
|
||||
sticker("surprised", R.drawable.sticker_face_surprised, "😲", FACES_PACK_ID)
|
||||
sticker("cool", R.drawable.sticker_face_cool, "😎", FACES_PACK_ID)
|
||||
sticker("angry", R.drawable.sticker_face_angry, "😡", FACES_PACK_ID)
|
||||
}
|
||||
|
||||
private val rasterized = mutableMapOf<Int, Bitmap>()
|
||||
|
||||
private fun rasterize(@DrawableRes resource: Int): Bitmap {
|
||||
return rasterized.getOrPut(resource) {
|
||||
val drawable = requireNotNull(context.getDrawable(resource))
|
||||
val bitmap = Bitmap.createBitmap(STICKER_SIZE, STICKER_SIZE, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
private val stickersByName: Map<String, KeyboardSticker> = demoStickers.associate { it.name to it.toKeyboardSticker() }
|
||||
private val recents = ArrayDeque<KeyboardSticker>()
|
||||
private val packsFlow = MutableStateFlow(buildPacks())
|
||||
|
||||
override fun observeStickerPacks(): Flow<List<KeyboardStickerPack>> = packsFlow
|
||||
|
||||
override fun onStickerUsed(sticker: KeyboardSticker) {
|
||||
recents.remove(sticker)
|
||||
recents.addFirst(sticker)
|
||||
while (recents.size > MAX_RECENTS) {
|
||||
recents.removeLast()
|
||||
}
|
||||
packsFlow.value = buildPacks()
|
||||
}
|
||||
|
||||
private fun buildPacks(): List<KeyboardStickerPack> {
|
||||
return buildList {
|
||||
if (recents.isNotEmpty()) {
|
||||
add(
|
||||
KeyboardStickerPack(
|
||||
id = StickerKeyboardRepository.RECENT_PACK_ID,
|
||||
title = null,
|
||||
cover = null,
|
||||
stickers = recents.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
add(pack(SHAPES_PACK_ID, "Shapes", R.drawable.sticker_star))
|
||||
add(pack(FACES_PACK_ID, "Faces", R.drawable.sticker_face_happy))
|
||||
}
|
||||
}
|
||||
|
||||
private fun pack(id: String, title: String, @DrawableRes cover: Int): KeyboardStickerPack {
|
||||
return KeyboardStickerPack(
|
||||
id = id,
|
||||
title = title,
|
||||
cover = rasterize(cover),
|
||||
stickers = demoStickers.filter { it.packId == id }.map { stickersByName.getValue(it.name) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun DemoSticker.toKeyboardSticker(): KeyboardSticker {
|
||||
return KeyboardSticker(
|
||||
packId = packId,
|
||||
packKey = "$packId-key",
|
||||
stickerId = id,
|
||||
emoji = emoji,
|
||||
image = rasterize(image)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHAPES_PACK_ID = "shapes"
|
||||
private const val FACES_PACK_ID = "faces"
|
||||
private const val MAX_RECENTS = 12
|
||||
private const val STICKER_SIZE = 512
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetAction
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetKey
|
||||
import org.signal.core.ui.compose.keyboard.KeyboardSheetScaffold
|
||||
import org.signal.core.ui.compose.keyboard.rememberKeyboardSheetController
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.glide.compose.GlideImageScaleType
|
||||
import org.signal.mediakeyboard.MediaKeyboard
|
||||
import org.signal.mediakeyboard.MediaKeyboardAction
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
|
||||
private val MEDIA_KEYBOARD = KeyboardSheetKey("demo.media")
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
state: MainScreenState,
|
||||
onEvent: (MainScreenEvents) -> Unit,
|
||||
repository: MediaKeyboardRepository,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val controller = rememberKeyboardSheetController()
|
||||
|
||||
val onKeyboardAction: (MediaKeyboardAction) -> Unit = remember(onEvent) {
|
||||
{ action ->
|
||||
when (action) {
|
||||
is MediaKeyboardAction.EmojiSelected -> onEvent(MainScreenEvents.EmojiSelected(action.emoji))
|
||||
MediaKeyboardAction.Backspace -> onEvent(MainScreenEvents.BackspacePressed)
|
||||
is MediaKeyboardAction.StickerSelected -> onEvent(MainScreenEvents.StickerSelected(action.sticker))
|
||||
is MediaKeyboardAction.GifSelected -> onEvent(MainScreenEvents.GifSelected(action.gif))
|
||||
// Nothing to remember the tab for, and no sticker packs to manage, view, or search.
|
||||
is MediaKeyboardAction.TabSelected,
|
||||
MediaKeyboardAction.StickerManagementClicked,
|
||||
MediaKeyboardAction.StickerSearchClicked,
|
||||
MediaKeyboardAction.GifSearchClicked,
|
||||
is MediaKeyboardAction.ViewStickerPackClicked -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.keyboardVisible) {
|
||||
if (state.keyboardVisible) controller.show(MEDIA_KEYBOARD) else controller.hide()
|
||||
}
|
||||
|
||||
KeyboardSheetScaffold(
|
||||
controller = controller,
|
||||
onAction = { action ->
|
||||
if (action is KeyboardSheetAction.KeyboardHidden) {
|
||||
onEvent(MainScreenEvents.MediaKeyboardDismissed)
|
||||
}
|
||||
},
|
||||
keyboardsProvider = {
|
||||
keyboard(key = MEDIA_KEYBOARD, expandable = true) {
|
||||
MediaKeyboard(
|
||||
repository = repository,
|
||||
onAction = onKeyboardAction
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessageList(
|
||||
messages = state.messages,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
ComposerBar(state = state, onEvent = onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageList(
|
||||
messages: List<DemoMessage>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
when (message) {
|
||||
is DemoMessage.Text -> TextMessage(message)
|
||||
is DemoMessage.Sticker -> StickerMessage(message)
|
||||
is DemoMessage.Gif -> GifMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextMessage(message: DemoMessage.Text) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerMessage(message: DemoMessage.Sticker) {
|
||||
GlideImage(
|
||||
model = message.sticker.image,
|
||||
modifier = Modifier.size(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GifMessage(message: DemoMessage.Gif) {
|
||||
GlideImage(
|
||||
model = message.gif.still,
|
||||
scaleType = GlideImageScaleType.CENTER_CROP,
|
||||
modifier = Modifier
|
||||
.width(220.dp)
|
||||
.aspectRatio(message.gif.aspectRatio)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposerBar(
|
||||
state: MainScreenState,
|
||||
onEvent: (MainScreenEvents) -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = { onEvent(MainScreenEvents.OpenMediaKeyboard) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = "Open media keyboard",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = state.composerText,
|
||||
onValueChange = { onEvent(MainScreenEvents.ComposerTextChanged(it)) },
|
||||
placeholder = { Text(text = "Message") },
|
||||
singleLine = true,
|
||||
shape = CircleShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = { onEvent(MainScreenEvents.SendClicked) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.main
|
||||
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.signal.mediakeyboard.data.KeyboardSticker
|
||||
|
||||
sealed class MainScreenEvents {
|
||||
data object OpenMediaKeyboard : MainScreenEvents()
|
||||
data object MediaKeyboardDismissed : MainScreenEvents()
|
||||
data class ComposerTextChanged(val text: String) : MainScreenEvents()
|
||||
data object SendClicked : MainScreenEvents()
|
||||
data class EmojiSelected(val emoji: String) : MainScreenEvents()
|
||||
data object BackspacePressed : MainScreenEvents()
|
||||
data class StickerSelected(val sticker: KeyboardSticker) : MainScreenEvents()
|
||||
data class GifSelected(val gif: KeyboardGif) : MainScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.main
|
||||
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.signal.mediakeyboard.data.KeyboardSticker
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
data class MainScreenState(
|
||||
val messages: List<DemoMessage> = emptyList(),
|
||||
val composerText: String = "",
|
||||
val keyboardVisible: Boolean = false
|
||||
)
|
||||
|
||||
sealed class DemoMessage {
|
||||
val id: Long = NEXT_ID.getAndIncrement()
|
||||
|
||||
data class Text(val text: String) : DemoMessage()
|
||||
data class Sticker(val sticker: KeyboardSticker) : DemoMessage()
|
||||
data class Gif(val gif: KeyboardGif) : DemoMessage()
|
||||
|
||||
private companion object {
|
||||
val NEXT_ID = AtomicLong()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.demo.main
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class MainScreenViewModel : EventDrivenViewModel<MainScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MainScreenViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(MainScreenState())
|
||||
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
||||
|
||||
override suspend fun processEvent(event: MainScreenEvents) {
|
||||
val state = _state.value
|
||||
|
||||
_state.value = when (event) {
|
||||
MainScreenEvents.OpenMediaKeyboard -> state.copy(keyboardVisible = true)
|
||||
|
||||
MainScreenEvents.MediaKeyboardDismissed -> state.copy(keyboardVisible = false)
|
||||
|
||||
is MainScreenEvents.ComposerTextChanged -> state.copy(composerText = event.text)
|
||||
|
||||
MainScreenEvents.SendClicked -> {
|
||||
if (state.composerText.isBlank()) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
messages = state.messages + DemoMessage.Text(state.composerText.trim()),
|
||||
composerText = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is MainScreenEvents.EmojiSelected -> state.copy(composerText = state.composerText + event.emoji)
|
||||
|
||||
MainScreenEvents.BackspacePressed -> state.copy(composerText = state.composerText.dropLastGrapheme())
|
||||
|
||||
is MainScreenEvents.StickerSelected -> state.copy(messages = state.messages + DemoMessage.Sticker(event.sticker))
|
||||
|
||||
is MainScreenEvents.GifSelected -> {
|
||||
state.copy(
|
||||
messages = state.messages + DemoMessage.Gif(event.gif),
|
||||
keyboardVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.dropLastGrapheme(): String {
|
||||
if (isEmpty()) {
|
||||
return this
|
||||
}
|
||||
|
||||
val iterator = BreakIteratorCompat.getInstance()
|
||||
iterator.setText(this)
|
||||
|
||||
return iterator.take(iterator.countBreaks() - 1).toString()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,175 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" android:fillColor="#FBC02D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#2196F3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2L22,12L12,22L2,12Z" android:fillColor="#9C27B0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M6.5,7l3,1.5 M17.5,7l-3,1.5" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
<path android:pathData="M7.5,16c1.2,-2.4 7.8,-2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M5,8.5h6v3.5h-6z M13,8.5h6v3.5h-6z M11,9.5h2v1h-2z" android:fillColor="#37474F"/>
|
||||
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M7.5,16c1.2,-2.4 7.8,-2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M15.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M12,15.5m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#4E342E"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20" android:fillColor="#FFD54F"/>
|
||||
<path android:pathData="M8.5,9.5m-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0" android:fillColor="#4E342E"/>
|
||||
<path android:pathData="M14,9.5h3" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
<path android:pathData="M7.5,13.5c1.2,2.4 7.8,2.4 9,0" android:strokeColor="#4E342E" android:strokeWidth="1.5" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" android:fillColor="#E91E63"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,3a9,9 0 1,0 9,9c0,-0.46 -0.04,-0.92 -0.1,-1.36a5.389,5.389 0 0,1 -4.4,2.26 5.403,5.403 0 0,1 -3.14,-9.8c-0.44,-0.06 -0.9,-0.1 -1.36,-0.1z" android:fillColor="#607D8B"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" android:fillColor="#FFC107"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M6.76,4.84l-1.8,-1.79 -1.41,1.41 1.79,1.79 1.42,-1.41zM4,10.5L1,10.5v2h3v-2zM13,0.55h-2L11,3.5h2L13,0.55zM20.45,4.46l-1.41,-1.41 -1.79,1.79 1.41,1.41 1.79,-1.79zM17.24,18.16l1.79,1.8 1.41,-1.41 -1.8,-1.79 -1.4,1.4zM20,10.5v2h3v-2h-3zM12,5.5c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM11,22.45h2L13,19.5h-2v2.95zM3.55,18.54l1.41,1.41 1.79,-1.8 -1.41,-1.41 -1.79,1.8z" android:fillColor="#FF9800"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M12,3L22,21L2,21Z" android:fillColor="#4CAF50"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,7 @@
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">MediaKeyboardDemo</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<resources>
|
||||
|
||||
<style name="Theme.MediaKeyboardDemo" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("signal-library")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.mediakeyboard"
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
|
||||
// Signal Core
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":lib:glide"))
|
||||
|
||||
// Compose BOM
|
||||
platform(libs.androidx.compose.bom).let { composeBom ->
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
}
|
||||
|
||||
// Compose dependencies
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.core)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
// Core AndroidX
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Media3 for gif (mp4) playback
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
|
||||
// Rendering custom emoji drawables
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
|
||||
// Testing
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
testImplementation(testLibs.robolectric.robolectric)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.outlined.Backspace
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.signal.core.ui.compose.keyboard.LocalKeyboardSheetController
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
import org.signal.mediakeyboard.screens.LocalMediaKeyboardExpanded
|
||||
import org.signal.mediakeyboard.screens.emoji.EmojiPageScreen
|
||||
import org.signal.mediakeyboard.screens.emoji.EmojiPageViewModel
|
||||
import org.signal.mediakeyboard.screens.gif.GifPageScreen
|
||||
import org.signal.mediakeyboard.screens.gif.GifPageViewModel
|
||||
import org.signal.mediakeyboard.screens.sticker.StickerPageScreen
|
||||
import org.signal.mediakeyboard.screens.sticker.StickerPageViewModel
|
||||
|
||||
/**
|
||||
* The emoji/sticker/gif media keyboard.
|
||||
*
|
||||
* Fills whatever space its host hands it, so the host owns the surface, its window insets, and any
|
||||
* drag handle. Search needs more room than a keyboard-sized space and a system keyboard to type the
|
||||
* query into, so it takes text entry over from the scaffold via
|
||||
* [org.signal.core.ui.compose.keyboard.KeyboardSheetController]; the scaffold is then the one
|
||||
* deciding the height and the system keyboard together.
|
||||
*
|
||||
* Layout: a top bar with search, text tabs, and a per-tab action, then the page content, with each
|
||||
* page providing its own bottom rail (emoji categories, sticker packs, gif quick searches).
|
||||
*
|
||||
* @param repository Supplies the emoji, sticker, and gif data.
|
||||
* @param onAction Receives what the user picks, and anything only a host can carry out.
|
||||
* @param tabs The only tabs to offer, or null to offer everything [repository] has. Hosts that
|
||||
* cannot accept every kind of media, such as a conversation editing a message, narrow this.
|
||||
* @param initialTab The tab to open on. A host that recorded [MediaKeyboardAction.TabSelected]
|
||||
* passes back what it recorded; null opens on the first tab offered.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaKeyboard(
|
||||
repository: MediaKeyboardRepository,
|
||||
onAction: (MediaKeyboardAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
tabs: Set<MediaKeyboardTab>? = null,
|
||||
initialTab: MediaKeyboardTab? = null
|
||||
) {
|
||||
val host = LocalKeyboardSheetController.current
|
||||
val viewModel: MediaKeyboardViewModel = viewModel(factory = MediaKeyboardViewModel.Factory(repository, host.isEnteringText, initialTab))
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(tabs) {
|
||||
viewModel.restrictTabs(tabs)
|
||||
}
|
||||
|
||||
val searching = state.searchActive
|
||||
|
||||
LaunchedEffect(searching) {
|
||||
if (searching) {
|
||||
viewModel.onEvent(MediaKeyboardScreenEvents.SearchQueryChanged(""))
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = searching) {
|
||||
host.endTextEntry()
|
||||
}
|
||||
|
||||
// Two search affordances at once is one too many, so the icon goes while a page's own field shows.
|
||||
var searchFieldRevealed by remember { mutableStateOf(false) }
|
||||
|
||||
// Reset on a tab change so a page without a field of its own never inherits the last page's answer.
|
||||
LaunchedEffect(state.selectedTab) {
|
||||
searchFieldRevealed = false
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMediaKeyboardExpanded provides host.isExpanded) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// The system keyboard the search field summons sits on top of us rather than beside us.
|
||||
.then(if (searching) Modifier.imePadding() else Modifier)
|
||||
) {
|
||||
if (searching) {
|
||||
MediaKeyboardSearchBar(state = state, onEvent = viewModel::onEvent, onCloseSearch = host::endTextEntry)
|
||||
} else {
|
||||
MediaKeyboardTopBar(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onAction = onAction,
|
||||
onOpenSearch = host::beginTextEntry,
|
||||
searchFieldRevealed = searchFieldRevealed
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
if (state.initialized) {
|
||||
when (state.selectedTab) {
|
||||
MediaKeyboardTab.EMOJI -> EmojiTab(repository, viewModel.state, onAction)
|
||||
MediaKeyboardTab.STICKER -> StickerTab(
|
||||
repository = repository,
|
||||
onAction = onAction,
|
||||
onSearchFieldRevealedChange = { searchFieldRevealed = it }
|
||||
)
|
||||
MediaKeyboardTab.GIF -> GifTab(
|
||||
repository = repository,
|
||||
onAction = onAction,
|
||||
onSearchFieldRevealedChange = { searchFieldRevealed = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaKeyboardTopBar(
|
||||
state: MediaKeyboardState,
|
||||
onEvent: (MediaKeyboardScreenEvents) -> Unit,
|
||||
onAction: (MediaKeyboardAction) -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
searchFieldRevealed: Boolean
|
||||
) {
|
||||
// A null action means searching in place, which emoji is now the only page to do.
|
||||
val searchAction = when (state.selectedTab) {
|
||||
MediaKeyboardTab.EMOJI -> null
|
||||
MediaKeyboardTab.STICKER -> MediaKeyboardAction.StickerSearchClicked
|
||||
MediaKeyboardTab.GIF -> MediaKeyboardAction.GifSearchClicked
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
) {
|
||||
// Kept in the layout so it fades in place and the tabs never shift.
|
||||
val searchAlpha by animateFloatAsState(
|
||||
targetValue = if (searchFieldRevealed) 0f else 1f,
|
||||
label = "searchAlpha"
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (searchAction != null) {
|
||||
onAction(searchAction)
|
||||
} else {
|
||||
onOpenSearch()
|
||||
}
|
||||
},
|
||||
enabled = searchAlpha > 0f,
|
||||
modifier = Modifier.graphicsLayer { alpha = searchAlpha }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = stringResource(R.string.MediaKeyboard__search),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (state.availableTabs.size > 1) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
state.availableTabs.forEach { tab ->
|
||||
TabPill(
|
||||
tab = tab,
|
||||
selected = tab == state.selectedTab,
|
||||
onClick = {
|
||||
onEvent(MediaKeyboardScreenEvents.TabSelected(tab))
|
||||
onAction(MediaKeyboardAction.TabSelected(tab))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (state.selectedTab) {
|
||||
MediaKeyboardTab.EMOJI -> {
|
||||
IconButton(onClick = { onAction(MediaKeyboardAction.Backspace) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Backspace,
|
||||
contentDescription = stringResource(R.string.MediaKeyboard__backspace),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
MediaKeyboardTab.STICKER -> {
|
||||
IconButton(onClick = { onAction(MediaKeyboardAction.StickerManagementClicked) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AddCircleOutline,
|
||||
contentDescription = stringResource(R.string.MediaKeyboard__manage_stickers),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
MediaKeyboardTab.GIF -> {
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabPill(
|
||||
tab: MediaKeyboardTab,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val background = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
|
||||
val textColor = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Text(
|
||||
text = stringResource(tab.label()),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(background)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiTab(
|
||||
repository: MediaKeyboardRepository,
|
||||
parentStateFlow: StateFlow<MediaKeyboardState>,
|
||||
onAction: (MediaKeyboardAction) -> Unit
|
||||
) {
|
||||
val viewModel: EmojiPageViewModel = viewModel(
|
||||
key = "media-keyboard-emoji",
|
||||
factory = EmojiPageViewModel.Factory(repository.emoji, parentStateFlow, onAction)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
EmojiPageScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
getEmojiDrawable = repository.emoji::getEmojiDrawable
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerTab(
|
||||
repository: MediaKeyboardRepository,
|
||||
onAction: (MediaKeyboardAction) -> Unit,
|
||||
onSearchFieldRevealedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val viewModel: StickerPageViewModel = viewModel(
|
||||
key = "media-keyboard-sticker",
|
||||
factory = StickerPageViewModel.Factory(repository.stickers, onAction)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
StickerPageScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onSearchFieldRevealedChange = onSearchFieldRevealedChange
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GifTab(
|
||||
repository: MediaKeyboardRepository,
|
||||
onAction: (MediaKeyboardAction) -> Unit,
|
||||
onSearchFieldRevealedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val viewModel: GifPageViewModel = viewModel(
|
||||
key = "media-keyboard-gif",
|
||||
factory = GifPageViewModel.Factory(repository.gifs, onAction)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
GifPageScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onSearchFieldRevealedChange = onSearchFieldRevealedChange
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaKeyboardSearchBar(
|
||||
state: MediaKeyboardState,
|
||||
onEvent: (MediaKeyboardScreenEvents) -> Unit,
|
||||
onCloseSearch: () -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
IconButton(onClick = onCloseSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.MediaKeyboard__close_search),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { onEvent(MediaKeyboardScreenEvents.SearchQueryChanged(it)) },
|
||||
placeholder = { Text(text = stringResource(state.selectedTab.searchHint())) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (state.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onEvent(MediaKeyboardScreenEvents.SearchQueryChanged("")) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Clear,
|
||||
contentDescription = stringResource(R.string.MediaKeyboard__clear_search),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = CircleShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun MediaKeyboardTab.label(): Int {
|
||||
return when (this) {
|
||||
MediaKeyboardTab.EMOJI -> R.string.MediaKeyboard__emoji
|
||||
MediaKeyboardTab.STICKER -> R.string.MediaKeyboard__stickers
|
||||
MediaKeyboardTab.GIF -> R.string.MediaKeyboard__gifs
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun MediaKeyboardTab.searchHint(): Int {
|
||||
return when (this) {
|
||||
MediaKeyboardTab.EMOJI -> R.string.MediaKeyboard__search_emoji
|
||||
MediaKeyboardTab.STICKER -> R.string.MediaKeyboard__search_stickers
|
||||
MediaKeyboardTab.GIF -> R.string.MediaKeyboard__search_gifs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.signal.mediakeyboard.data.KeyboardSticker
|
||||
|
||||
/**
|
||||
* Side effects emitted by a [MediaKeyboard] that need to be handled by the user of the component:
|
||||
* what the user picked, and the things only a host can carry out.
|
||||
*
|
||||
* Requests flow the other way as [MediaKeyboardScreenEvents], and everything about the space the
|
||||
* keyboard is given is the scaffold's, as
|
||||
* [org.signal.core.ui.compose.keyboard.KeyboardSheetAction].
|
||||
*/
|
||||
sealed interface MediaKeyboardAction {
|
||||
|
||||
/** @param emoji The emoji to insert, already resolved to the user's preferred variation. */
|
||||
data class EmojiSelected(val emoji: String) : MediaKeyboardAction
|
||||
|
||||
/** Delete backwards from wherever the host's own field has its cursor. */
|
||||
data object Backspace : MediaKeyboardAction
|
||||
|
||||
/** @param sticker The sticker to send. */
|
||||
data class StickerSelected(val sticker: KeyboardSticker) : MediaKeyboardAction
|
||||
|
||||
/** @param gif The gif to send. */
|
||||
data class GifSelected(val gif: KeyboardGif) : MediaKeyboardAction
|
||||
|
||||
/**
|
||||
* The user switched tabs. The keyboard has no memory of its own across the times it is shown, so
|
||||
* a host that wants to reopen on the tab last picked has to record this.
|
||||
*
|
||||
* @param tab The tab now showing.
|
||||
*/
|
||||
data class TabSelected(val tab: MediaKeyboardTab) : MediaKeyboardAction
|
||||
|
||||
/** Take the user to wherever sticker packs are managed. */
|
||||
data object StickerManagementClicked : MediaKeyboardAction
|
||||
|
||||
/**
|
||||
* Take the user to sticker search, which is a screen of its own rather than a field in the
|
||||
* keyboard. Emitted by both the sticker page's search field and the top bar's search icon.
|
||||
*/
|
||||
data object StickerSearchClicked : MediaKeyboardAction
|
||||
|
||||
/** Take the user to gif search. As [StickerSearchClicked], but for the gif page. */
|
||||
data object GifSearchClicked : MediaKeyboardAction
|
||||
|
||||
/**
|
||||
* Show the user everything in a sticker pack.
|
||||
*
|
||||
* @param packId The pack to open.
|
||||
* @param packKey Its key, which identifies the pack alongside [packId].
|
||||
*/
|
||||
data class ViewStickerPackClicked(val packId: String, val packKey: String) : MediaKeyboardAction
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
/**
|
||||
* User interactions with a [MediaKeyboard], applied by [MediaKeyboardViewModel].
|
||||
*/
|
||||
sealed interface MediaKeyboardScreenEvents {
|
||||
data object Initialize : MediaKeyboardScreenEvents
|
||||
data class TabSelected(val tab: MediaKeyboardTab) : MediaKeyboardScreenEvents
|
||||
data class SearchQueryChanged(val query: String) : MediaKeyboardScreenEvents
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
/**
|
||||
* @param offeredTabs Everything the repository has to offer, before any restriction.
|
||||
* @param restrictedTo Narrows the keyboard on the host's behalf, or null for no restriction.
|
||||
* @param preferredTab The tab the user last picked, returned to whenever it is reachable again.
|
||||
* Seeded by the host from what it recorded of [MediaKeyboardAction.TabSelected], since the
|
||||
* keyboard has no memory of its own across the times it is shown.
|
||||
* @param searchActive Whether the search surface is up. Owned by the scaffold rather than tracked
|
||||
* here, and not the events' to set; see
|
||||
* [org.signal.core.ui.compose.keyboard.KeyboardSheetController.isEnteringText].
|
||||
* @param searchQuery What the user has typed, which is this keyboard's own to keep. Only meaningful
|
||||
* while [searchActive]; left behind rather than cleared when search closes.
|
||||
*/
|
||||
data class MediaKeyboardState(
|
||||
val offeredTabs: List<MediaKeyboardTab> = emptyList(),
|
||||
val restrictedTo: Set<MediaKeyboardTab>? = null,
|
||||
val preferredTab: MediaKeyboardTab = MediaKeyboardTab.EMOJI,
|
||||
val searchActive: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val initialized: Boolean = false
|
||||
) {
|
||||
/** What the user can actually reach. A single tab hides the tab row altogether. */
|
||||
val availableTabs: List<MediaKeyboardTab> = offeredTabs.filter { restrictedTo == null || it in restrictedTo }
|
||||
|
||||
val selectedTab: MediaKeyboardTab = availableTabs.firstOrNull { it == preferredTab }
|
||||
?: availableTabs.firstOrNull()
|
||||
?: MediaKeyboardTab.EMOJI
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
/**
|
||||
* The top-level content types available in the media keyboard.
|
||||
*/
|
||||
enum class MediaKeyboardTab {
|
||||
EMOJI,
|
||||
STICKER,
|
||||
GIF
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.data
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.StringRes
|
||||
import org.signal.mediakeyboard.R
|
||||
|
||||
/**
|
||||
* Data source for the emoji page of the media keyboard.
|
||||
*/
|
||||
interface EmojiKeyboardRepository {
|
||||
|
||||
/**
|
||||
* All emoji, grouped into display categories. Should not include [EmojiKeyboardCategory.RECENTS],
|
||||
* which is fetched separately via [getRecentEmoji].
|
||||
*/
|
||||
suspend fun getEmojiPages(): List<EmojiCategoryPage>
|
||||
|
||||
/**
|
||||
* Recently-used emoji, most recent first. May be empty.
|
||||
*/
|
||||
suspend fun getRecentEmoji(): List<KeyboardEmoji>
|
||||
|
||||
/**
|
||||
* Emoji matching the given search query.
|
||||
*/
|
||||
suspend fun search(query: String): List<KeyboardEmoji>
|
||||
|
||||
/**
|
||||
* The user's preferred (e.g. skin tone) variation for each canonical emoji.
|
||||
*/
|
||||
suspend fun getPreferredVariations(): Map<String, String>
|
||||
|
||||
fun setPreferredVariation(canonical: String, variation: String)
|
||||
|
||||
/**
|
||||
* Called whenever the user picks an emoji, so that recents can be tracked.
|
||||
*/
|
||||
suspend fun onEmojiUsed(emoji: String)
|
||||
|
||||
/**
|
||||
* A custom drawable to render the given emoji with, or null to render it as plain text with
|
||||
* the system emoji font.
|
||||
*/
|
||||
fun getEmojiDrawable(emoji: String): Drawable? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* A single selectable emoji.
|
||||
*
|
||||
* @param canonical The default form of the emoji.
|
||||
* @param variations All selectable variations (e.g. skin tones), including the canonical form.
|
||||
* Empty if the emoji has no variations.
|
||||
*/
|
||||
data class KeyboardEmoji(
|
||||
val canonical: String,
|
||||
val variations: List<String> = emptyList()
|
||||
) {
|
||||
val hasVariations: Boolean = variations.size > 1
|
||||
}
|
||||
|
||||
data class EmojiCategoryPage(
|
||||
val category: EmojiKeyboardCategory,
|
||||
val emoji: List<KeyboardEmoji>
|
||||
)
|
||||
|
||||
enum class EmojiKeyboardCategory(val key: String, @field:StringRes val label: Int) {
|
||||
RECENTS("Recents", R.string.MediaKeyboard__recently_used),
|
||||
PEOPLE("People", R.string.MediaKeyboard__smileys_and_people),
|
||||
NATURE("Nature", R.string.MediaKeyboard__nature),
|
||||
FOODS("Foods", R.string.MediaKeyboard__food),
|
||||
ACTIVITY("Activity", R.string.MediaKeyboard__activities),
|
||||
PLACES("Places", R.string.MediaKeyboard__places),
|
||||
OBJECTS("Objects", R.string.MediaKeyboard__objects),
|
||||
SYMBOLS("Symbols", R.string.MediaKeyboard__symbols),
|
||||
FLAGS("Flags", R.string.MediaKeyboard__flags),
|
||||
EMOTICONS("Emoticons", R.string.MediaKeyboard__emoticons)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.data
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.Result
|
||||
|
||||
/**
|
||||
* Data source for the gif page of the media keyboard.
|
||||
*/
|
||||
interface GifKeyboardRepository {
|
||||
|
||||
/**
|
||||
* Fetches a page of gifs. An empty [query] means trending.
|
||||
*/
|
||||
suspend fun getGifs(query: String, offset: Int, limit: Int): Result<GifPage, GifFetchError>
|
||||
}
|
||||
|
||||
data class GifPage(
|
||||
val gifs: List<KeyboardGif>,
|
||||
val hasMore: Boolean
|
||||
)
|
||||
|
||||
sealed interface GifFetchError {
|
||||
data object Network : GifFetchError
|
||||
data class Unknown(val cause: Throwable? = null) : GifFetchError
|
||||
}
|
||||
|
||||
/**
|
||||
* A single selectable gif.
|
||||
*
|
||||
* @param still A Glide-loadable model for a static preview image (e.g. a url or resource id).
|
||||
* @param mp4PreviewUri A looping mp4 rendition to autoplay in the grid, if available.
|
||||
*/
|
||||
data class KeyboardGif(
|
||||
val id: String,
|
||||
val still: Any?,
|
||||
val mp4PreviewUri: Uri?,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
) {
|
||||
val aspectRatio: Float = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else 1f
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.data
|
||||
|
||||
import org.signal.mediakeyboard.MediaKeyboardTab
|
||||
|
||||
/**
|
||||
* The full set of data sources the media keyboard needs to operate. The main app provides
|
||||
* implementations backed by its real data stores, while demo apps can provide fakes.
|
||||
*/
|
||||
interface MediaKeyboardRepository {
|
||||
val emoji: EmojiKeyboardRepository
|
||||
val stickers: StickerKeyboardRepository
|
||||
val gifs: GifKeyboardRepository
|
||||
|
||||
/**
|
||||
* Which tabs should be shown. e.g. the gif tab may be disabled via remote config, or the
|
||||
* sticker tab hidden if the user has no packs.
|
||||
*/
|
||||
suspend fun getAvailableTabs(): Set<MediaKeyboardTab> = MediaKeyboardTab.entries.toSet()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Data source for the sticker page of the media keyboard.
|
||||
*/
|
||||
interface StickerKeyboardRepository {
|
||||
|
||||
/**
|
||||
* Whether animated (e.g. APNG) stickers should animate on this device.
|
||||
*/
|
||||
val allowStickerAnimation: Boolean
|
||||
get() = true
|
||||
|
||||
/**
|
||||
* The user's installed sticker packs, re-emitted whenever the underlying data changes.
|
||||
* If the user has recently-used stickers, the first pack should be a synthetic pack with
|
||||
* id [RECENT_PACK_ID].
|
||||
*/
|
||||
fun observeStickerPacks(): Flow<List<KeyboardStickerPack>>
|
||||
|
||||
/**
|
||||
* Called whenever the user picks a sticker, so that recents can be tracked.
|
||||
*/
|
||||
fun onStickerUsed(sticker: KeyboardSticker) = Unit
|
||||
|
||||
companion object {
|
||||
const val RECENT_PACK_ID = "media-keyboard-recents"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single selectable sticker.
|
||||
*
|
||||
* @param packKey The key of the pack this came from, which identifies the pack alongside [packId]
|
||||
* wherever it is opened. Carried on the sticker rather than the pack because recents mixes packs.
|
||||
* @param image A Glide-loadable model for the sticker image (e.g. a Uri or resource id).
|
||||
* @param isAnimated Whether the image is an animated (APNG) sticker.
|
||||
*/
|
||||
data class KeyboardSticker(
|
||||
val packId: String,
|
||||
val packKey: String,
|
||||
val stickerId: Long,
|
||||
val emoji: String?,
|
||||
val image: Any,
|
||||
val isAnimated: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* @param cover A Glide-loadable model for the pack's cover image.
|
||||
*/
|
||||
data class KeyboardStickerPack(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val cover: Any?,
|
||||
val stickers: List<KeyboardSticker>
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.SignalPreviewWrapper
|
||||
import org.signal.mediakeyboard.R
|
||||
|
||||
/**
|
||||
* The content padding of a page's grid.
|
||||
*
|
||||
* Shared with the pages rather than left to each of them because opening scrolled past
|
||||
* [MediaKeyboardSearchField] means scrolling the leading padding away too, and a page that measured
|
||||
* a different value than it laid out with would leave the field peeking.
|
||||
*/
|
||||
internal val GRID_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
||||
/**
|
||||
* Looks like a field but is not one: tapping it hands search off to the host, which has a whole
|
||||
* screen to give it rather than the strip of keyboard available here.
|
||||
*
|
||||
* Pages put this first in their scrolling content and open scrolled past it, so it is something the
|
||||
* user pulls down to rather than something holding a row of results' worth of room.
|
||||
*
|
||||
* @param hint What is being searched, shown in place of anything typed.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MediaKeyboardSearchField(
|
||||
hint: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(50))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Search.imageVector,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewWrapper(SignalPreviewWrapper::class)
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaKeyboardSearchFieldPreview() {
|
||||
MediaKeyboardSearchField(
|
||||
hint = stringResource(R.string.MediaKeyboard__search_stickers),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a page's grid scrolled past its [MediaKeyboardSearchField] and reports when the user has
|
||||
* pulled back down to it, so the top bar can drop its now-redundant search icon.
|
||||
*
|
||||
* Effects only. The two pages hold different kinds of grid state, which share no supertype, so the
|
||||
* grid is reached through lambdas rather than passed in.
|
||||
*
|
||||
* @param hasContent Whether the grid has anything to scroll yet; until it does there is nothing to
|
||||
* hide the field behind.
|
||||
* @param firstVisibleItemIndex The grid's first visible item. Read inside a [derivedStateOf] so
|
||||
* that scrolling does not recompose the page.
|
||||
* @param scrollPastField Scrolls the grid to an item and offset.
|
||||
* @param onRevealedChange Receives whether the field is showing.
|
||||
* @param resetKey Hides the field again whenever this changes, for a grid whose contents are
|
||||
* replaced and whose scroll goes back to the top with them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SearchFieldReveal(
|
||||
hasContent: Boolean,
|
||||
firstVisibleItemIndex: () -> Int,
|
||||
scrollPastField: suspend (index: Int, offset: Int) -> Unit,
|
||||
onRevealedChange: (Boolean) -> Unit,
|
||||
resetKey: Any? = Unit
|
||||
) {
|
||||
// Scrolling to an item stops at the leading content padding rather than the top of the viewport,
|
||||
// and that gap is just enough to leave the bottom of the field showing, so it goes too.
|
||||
val topContentPaddingPx = with(LocalDensity.current) { GRID_CONTENT_PADDING.calculateTopPadding().roundToPx() }
|
||||
|
||||
var scrolledPast by remember(resetKey) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(resetKey, hasContent) {
|
||||
if (hasContent && !scrolledPast) {
|
||||
scrollPastField(1, topContentPaddingPx)
|
||||
scrolledPast = true
|
||||
}
|
||||
}
|
||||
|
||||
val currentFirstVisibleItemIndex by rememberUpdatedState(firstVisibleItemIndex)
|
||||
val revealed by remember { derivedStateOf { currentFirstVisibleItemIndex() == 0 } }
|
||||
|
||||
// Held back until the grid has settled where it opens, or the field would be reported as showing
|
||||
// for the frames before that first scroll and the top bar's icon would blink out and back.
|
||||
LaunchedEffect(revealed, scrolledPast) {
|
||||
onRevealedChange(scrolledPast && revealed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.emoji
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.EmojiEvents
|
||||
import androidx.compose.material.icons.outlined.EmojiFlags
|
||||
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
|
||||
import androidx.compose.material.icons.outlined.EmojiNature
|
||||
import androidx.compose.material.icons.outlined.EmojiObjects
|
||||
import androidx.compose.material.icons.outlined.EmojiSymbols
|
||||
import androidx.compose.material.icons.outlined.EmojiTransportation
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.SentimentSatisfied
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.SignalPreviewWrapper
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediakeyboard.R
|
||||
import org.signal.mediakeyboard.data.EmojiCategoryPage
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.KeyboardEmoji
|
||||
import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING
|
||||
import org.signal.mediakeyboard.screens.PinnedRailLayout
|
||||
|
||||
@Composable
|
||||
fun EmojiPageScreen(
|
||||
state: EmojiPageState,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
getEmojiDrawable: (String) -> Drawable? = { null }
|
||||
) {
|
||||
val searching = state.searchResults != null
|
||||
|
||||
PinnedRailLayout(
|
||||
rail = if (searching) {
|
||||
null
|
||||
} else {
|
||||
{ EmojiCategoryRail(state = state, onEvent = onEvent) }
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
if (searching) {
|
||||
EmojiSearchResults(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
getEmojiDrawable = getEmojiDrawable
|
||||
)
|
||||
} else {
|
||||
EmojiGrid(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
getEmojiDrawable = getEmojiDrawable
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiCategoryRail(
|
||||
state: EmojiPageState,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit
|
||||
) {
|
||||
Surface(color = SignalTheme.colors.colorSurface5) {
|
||||
LazyRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
items(state.pages, key = { it.category.key }) { page ->
|
||||
CategoryButton(
|
||||
category = page.category,
|
||||
selected = page.category == state.selectedCategory,
|
||||
onClick = { onEvent(EmojiPageScreenEvents.CategorySelected(page.category)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryButton(
|
||||
category: EmojiKeyboardCategory,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = category.icon(),
|
||||
contentDescription = stringResource(category.label),
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = if (selected) {
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CircleShape)
|
||||
.padding(6.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiGrid(
|
||||
state: EmojiPageState,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit,
|
||||
getEmojiDrawable: (String) -> Drawable?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
val headerIndices = remember(state.pages) {
|
||||
var index = 0
|
||||
buildMap {
|
||||
state.pages.forEach { page ->
|
||||
put(page.category, index)
|
||||
index += 1 + page.emoji.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start scrolled just past the first section header so more content is visible; scrolling up
|
||||
// still reveals it.
|
||||
LaunchedEffect(state.pages.isNotEmpty()) {
|
||||
if (state.pages.isNotEmpty()) {
|
||||
gridState.scrollToItem(1)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.scrollTarget) {
|
||||
val target = state.scrollTarget ?: return@LaunchedEffect
|
||||
headerIndices[target]?.let { gridState.scrollToItem(it) }
|
||||
onEvent(EmojiPageScreenEvents.ScrollTargetConsumed)
|
||||
}
|
||||
|
||||
val visibleCategory by remember(state.pages, headerIndices) {
|
||||
derivedStateOf {
|
||||
val firstVisible = gridState.firstVisibleItemIndex
|
||||
state.pages
|
||||
.lastOrNull { page -> (headerIndices[page.category] ?: Int.MAX_VALUE) <= firstVisible }
|
||||
?.category
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visibleCategory) {
|
||||
visibleCategory?.let { onEvent(EmojiPageScreenEvents.VisibleCategoryChanged(it)) }
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 44.dp),
|
||||
state = gridState,
|
||||
contentPadding = GRID_CONTENT_PADDING,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
state.pages.forEach { page ->
|
||||
item(key = "header:${page.category.key}", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
text = stringResource(page.category.label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
page.emoji.forEachIndexed { index, emoji ->
|
||||
val cellKey = "${page.category.key}:$index"
|
||||
item(key = cellKey) {
|
||||
EmojiCell(
|
||||
emoji = emoji,
|
||||
display = state.displayEmoji(emoji),
|
||||
cellKey = cellKey,
|
||||
showVariationSelector = state.variationSelector?.cellKey == cellKey,
|
||||
onEvent = onEvent,
|
||||
getEmojiDrawable = getEmojiDrawable,
|
||||
modifier = Modifier.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiSearchResults(
|
||||
state: EmojiPageState,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit,
|
||||
getEmojiDrawable: (String) -> Drawable?
|
||||
) {
|
||||
val results = state.searchResults ?: return
|
||||
|
||||
if (results.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (state.searchQuery.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.MediaKeyboard__no_results_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 44.dp),
|
||||
contentPadding = GRID_CONTENT_PADDING,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
results.forEachIndexed { index, emoji ->
|
||||
val cellKey = "search:$index"
|
||||
item(key = cellKey) {
|
||||
EmojiCell(
|
||||
emoji = emoji,
|
||||
display = state.displayEmoji(emoji),
|
||||
cellKey = cellKey,
|
||||
showVariationSelector = state.variationSelector?.cellKey == cellKey,
|
||||
onEvent = onEvent,
|
||||
getEmojiDrawable = getEmojiDrawable,
|
||||
modifier = Modifier.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EmojiCell(
|
||||
emoji: KeyboardEmoji,
|
||||
display: String,
|
||||
cellKey: String,
|
||||
showVariationSelector: Boolean,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit,
|
||||
getEmojiDrawable: (String) -> Drawable?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = { onEvent(EmojiPageScreenEvents.EmojiClicked(emoji)) },
|
||||
onLongClick = { onEvent(EmojiPageScreenEvents.EmojiLongPressed(cellKey, emoji)) }
|
||||
)
|
||||
) {
|
||||
EmojiImage(emoji = display, getEmojiDrawable = getEmojiDrawable)
|
||||
|
||||
if (emoji.hasVariations) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(5.dp)
|
||||
.size(4.dp)
|
||||
.background(MaterialTheme.colorScheme.outline, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
if (showVariationSelector) {
|
||||
VariationSelectorPopup(
|
||||
emoji = emoji,
|
||||
onEvent = onEvent,
|
||||
getEmojiDrawable = getEmojiDrawable
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariationSelectorPopup(
|
||||
emoji: KeyboardEmoji,
|
||||
onEvent: (EmojiPageScreenEvents) -> Unit,
|
||||
getEmojiDrawable: (String) -> Drawable?
|
||||
) {
|
||||
val yOffset = with(LocalDensity.current) { -56.dp.roundToPx() }
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopCenter,
|
||||
offset = IntOffset(0, yOffset),
|
||||
onDismissRequest = { onEvent(EmojiPageScreenEvents.VariationSelectorDismissed) },
|
||||
properties = PopupProperties(focusable = true)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
emoji.variations.forEach { variation ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { onEvent(EmojiPageScreenEvents.VariationSelected(emoji, variation)) }
|
||||
) {
|
||||
EmojiImage(emoji = variation, getEmojiDrawable = getEmojiDrawable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiImage(
|
||||
emoji: String,
|
||||
getEmojiDrawable: (String) -> Drawable?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val drawable = remember(emoji) { getEmojiDrawable(emoji) }
|
||||
|
||||
if (drawable != null) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(drawable),
|
||||
contentDescription = emoji,
|
||||
modifier = modifier.size(26.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = if (emoji.isAsciiEmoticon()) 13.sp else 22.sp,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isAsciiEmoticon(): Boolean = all { it.code < 128 }
|
||||
|
||||
private fun EmojiKeyboardCategory.icon(): ImageVector {
|
||||
return when (this) {
|
||||
EmojiKeyboardCategory.RECENTS -> Icons.Outlined.Schedule
|
||||
EmojiKeyboardCategory.PEOPLE -> Icons.Outlined.EmojiEmotions
|
||||
EmojiKeyboardCategory.NATURE -> Icons.Outlined.EmojiNature
|
||||
EmojiKeyboardCategory.FOODS -> Icons.Outlined.EmojiFoodBeverage
|
||||
EmojiKeyboardCategory.ACTIVITY -> Icons.Outlined.EmojiEvents
|
||||
EmojiKeyboardCategory.PLACES -> Icons.Outlined.EmojiTransportation
|
||||
EmojiKeyboardCategory.OBJECTS -> Icons.Outlined.EmojiObjects
|
||||
EmojiKeyboardCategory.SYMBOLS -> Icons.Outlined.EmojiSymbols
|
||||
EmojiKeyboardCategory.FLAGS -> Icons.Outlined.EmojiFlags
|
||||
EmojiKeyboardCategory.EMOTICONS -> Icons.Outlined.SentimentSatisfied
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewWrapper(SignalPreviewWrapper::class)
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun EmojiPageScreenPreview() {
|
||||
EmojiPageScreen(
|
||||
state = EmojiPageState(
|
||||
pages = listOf(
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.PEOPLE,
|
||||
emoji = listOf("😀", "😂", "🥰", "😎", "🤔", "😢", "😡", "🥳").map { KeyboardEmoji(it) } +
|
||||
KeyboardEmoji("👍", listOf("👍", "👍🏻", "👍🏼", "👍🏽", "👍🏾", "👍🏿"))
|
||||
),
|
||||
EmojiCategoryPage(
|
||||
category = EmojiKeyboardCategory.NATURE,
|
||||
emoji = listOf("🐶", "🐱", "🦊", "🌸", "🌈").map { KeyboardEmoji(it) }
|
||||
)
|
||||
),
|
||||
selectedCategory = EmojiKeyboardCategory.PEOPLE
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.emoji
|
||||
|
||||
import org.signal.mediakeyboard.MediaKeyboardState
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.KeyboardEmoji
|
||||
|
||||
sealed interface EmojiPageScreenEvents {
|
||||
data object Initialize : EmojiPageScreenEvents
|
||||
data class ParentStateChanged(val parentState: MediaKeyboardState) : EmojiPageScreenEvents
|
||||
data class CategorySelected(val category: EmojiKeyboardCategory) : EmojiPageScreenEvents
|
||||
data class VisibleCategoryChanged(val category: EmojiKeyboardCategory) : EmojiPageScreenEvents
|
||||
data object ScrollTargetConsumed : EmojiPageScreenEvents
|
||||
data class EmojiClicked(val emoji: KeyboardEmoji) : EmojiPageScreenEvents
|
||||
data class EmojiLongPressed(val cellKey: String, val emoji: KeyboardEmoji) : EmojiPageScreenEvents
|
||||
data class VariationSelected(val emoji: KeyboardEmoji, val variation: String) : EmojiPageScreenEvents
|
||||
data object VariationSelectorDismissed : EmojiPageScreenEvents
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.emoji
|
||||
|
||||
import org.signal.mediakeyboard.data.EmojiCategoryPage
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.KeyboardEmoji
|
||||
|
||||
data class EmojiPageState(
|
||||
val pages: List<EmojiCategoryPage> = emptyList(),
|
||||
val selectedCategory: EmojiKeyboardCategory? = null,
|
||||
val preferredVariations: Map<String, String> = emptyMap(),
|
||||
val variationSelector: VariationSelectorTarget? = null,
|
||||
val scrollTarget: EmojiKeyboardCategory? = null,
|
||||
val searchResults: List<KeyboardEmoji>? = null,
|
||||
val searchQuery: String = ""
|
||||
) {
|
||||
|
||||
/**
|
||||
* Identifies the exact grid cell (via its lazy-grid key) that should display the
|
||||
* skin tone variation popup.
|
||||
*/
|
||||
data class VariationSelectorTarget(
|
||||
val cellKey: String,
|
||||
val emoji: KeyboardEmoji
|
||||
)
|
||||
|
||||
fun displayEmoji(emoji: KeyboardEmoji): String {
|
||||
return preferredVariations[emoji.canonical] ?: emoji.canonical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.gif
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.PlayerSurface
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.glide.compose.GlideImageScaleType
|
||||
import org.signal.mediakeyboard.R
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING
|
||||
import org.signal.mediakeyboard.screens.MediaKeyboardSearchField
|
||||
import org.signal.mediakeyboard.screens.PinnedRailLayout
|
||||
import org.signal.mediakeyboard.screens.SearchFieldReveal
|
||||
|
||||
/** The search field ahead of the gifs, which item indices have to be shifted back past. */
|
||||
private const val SEARCH_FIELD_ITEMS = 1
|
||||
|
||||
/**
|
||||
* @param onSearchFieldRevealedChange Reports whether the grid is scrolled far enough up to show the
|
||||
* search field, so the top bar can drop its now-redundant search icon.
|
||||
*/
|
||||
@Composable
|
||||
fun GifPageScreen(
|
||||
state: GifPageState,
|
||||
onEvent: (GifPageScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onSearchFieldRevealedChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
// Only the grid carries the field, so nothing is revealed while loading, retrying or empty.
|
||||
val showingGrid = !state.isLoading && !state.loadFailed && state.gifs.isNotEmpty()
|
||||
|
||||
LaunchedEffect(showingGrid) {
|
||||
if (!showingGrid) {
|
||||
onSearchFieldRevealedChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
PinnedRailLayout(
|
||||
rail = { GifQuickSearchRail(state = state, onEvent = onEvent) },
|
||||
modifier = modifier
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
state.isLoading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
state.loadFailed -> {
|
||||
GifLoadError(onEvent = onEvent)
|
||||
}
|
||||
|
||||
state.gifs.isEmpty() -> {
|
||||
Text(
|
||||
text = stringResource(R.string.MediaKeyboard__no_results_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
GifGrid(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
onSearchFieldRevealedChange = onSearchFieldRevealedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GifQuickSearchRail(
|
||||
state: GifPageState,
|
||||
onEvent: (GifPageScreenEvents) -> Unit
|
||||
) {
|
||||
Surface(color = SignalTheme.colors.colorSurface5) {
|
||||
LazyRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
items(GifQuickSearchOption.entries, key = { it.name }) { option ->
|
||||
FilterChip(
|
||||
selected = option == state.selectedQuickSearch,
|
||||
onClick = { onEvent(GifPageScreenEvents.QuickSearchSelected(option)) },
|
||||
label = { Text(text = stringResource(option.label)) },
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GifLoadError(onEvent: (GifPageScreenEvents) -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MediaKeyboard__couldnt_load_gifs),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
|
||||
TextButton(onClick = { onEvent(GifPageScreenEvents.RetryClicked) }) {
|
||||
Text(text = stringResource(R.string.MediaKeyboard__retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GifGrid(
|
||||
state: GifPageState,
|
||||
onEvent: (GifPageScreenEvents) -> Unit,
|
||||
onSearchFieldRevealedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val gridState = rememberLazyStaggeredGridState()
|
||||
val playerPool = rememberGifPlayerPool()
|
||||
|
||||
// Keyed on the quick search, since switching one empties the grid and takes the scroll back to
|
||||
// the top with it.
|
||||
SearchFieldReveal(
|
||||
hasContent = state.gifs.isNotEmpty(),
|
||||
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
|
||||
scrollPastField = gridState::scrollToItem,
|
||||
onRevealedChange = onSearchFieldRevealedChange,
|
||||
resetKey = state.selectedQuickSearch
|
||||
)
|
||||
|
||||
val shouldLoadMore by remember(state.gifs.size) {
|
||||
derivedStateOf {
|
||||
val lastVisibleGif = (gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) - SEARCH_FIELD_ITEMS
|
||||
lastVisibleGif >= state.gifs.size - 8
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadMore, state.gifs.size) {
|
||||
if (shouldLoadMore) {
|
||||
onEvent(GifPageScreenEvents.LoadMoreRequested)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
state = gridState,
|
||||
verticalItemSpacing = 4.dp,
|
||||
contentPadding = GRID_CONTENT_PADDING,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item(key = "search", span = StaggeredGridItemSpan.FullLine) {
|
||||
MediaKeyboardSearchField(
|
||||
hint = stringResource(R.string.MediaKeyboard__search_gifs),
|
||||
onClick = { onEvent(GifPageScreenEvents.SearchClicked) }
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
count = state.gifs.size,
|
||||
key = { index -> "${state.gifs[index].id}:$index" }
|
||||
) { index ->
|
||||
val gif = state.gifs[index]
|
||||
GifCell(
|
||||
gif = gif,
|
||||
playerPool = playerPool,
|
||||
onClick = { onEvent(GifPageScreenEvents.GifClicked(gif)) },
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoadingMore) {
|
||||
item(key = "loading-more", span = StaggeredGridItemSpan.FullLine) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
private fun GifCell(
|
||||
gif: KeyboardGif,
|
||||
playerPool: GifPlayerPool,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(gif.aspectRatio)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
GlideImage(
|
||||
model = gif.still,
|
||||
scaleType = GlideImageScaleType.CENTER_CROP,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
val mp4PreviewUri = gif.mp4PreviewUri
|
||||
if (mp4PreviewUri != null) {
|
||||
var player by remember(gif.id) { mutableStateOf<Player?>(null) }
|
||||
|
||||
DisposableEffect(gif.id) {
|
||||
player = playerPool.acquire(gif.id, mp4PreviewUri)
|
||||
onDispose {
|
||||
playerPool.release(gif.id)
|
||||
player = null
|
||||
}
|
||||
}
|
||||
|
||||
player?.let {
|
||||
val presentationState = rememberPresentationState(it)
|
||||
|
||||
PlayerSurface(
|
||||
player = it,
|
||||
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
|
||||
modifier = Modifier.resizeWithContentScale(ContentScale.Crop, presentationState.videoSizeDp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.gif
|
||||
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
|
||||
sealed interface GifPageScreenEvents {
|
||||
data object Initialize : GifPageScreenEvents
|
||||
data class QuickSearchSelected(val option: GifQuickSearchOption) : GifPageScreenEvents
|
||||
data object LoadMoreRequested : GifPageScreenEvents
|
||||
data object RetryClicked : GifPageScreenEvents
|
||||
data class GifClicked(val gif: KeyboardGif) : GifPageScreenEvents
|
||||
data object SearchClicked : GifPageScreenEvents
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.gif
|
||||
|
||||
import org.signal.mediakeyboard.data.KeyboardGif
|
||||
|
||||
data class GifPageState(
|
||||
val selectedQuickSearch: GifQuickSearchOption = GifQuickSearchOption.TRENDING,
|
||||
val gifs: List<KeyboardGif> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val hasMore: Boolean = true,
|
||||
val loadFailed: Boolean = false
|
||||
)
|
||||