From aaa388e57015b2b01e82601b5517129fef07eef7 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:34:04 +0400 Subject: [PATCH 1/3] desktop: add emoji picker (#7708) * desktop: add emoji picker data and generator * desktop: emoji picker in compose field * desktop: document compose emoji picker --- .../views/chat/ComposeEmojiPicker.android.kt | 7 + .../common/views/chat/ComposeEmojiPicker.kt | 14 + .../simplex/common/views/chat/ComposeView.kt | 19 +- .../moko-resources/base/strings.xml | 11 + .../chat/simplex/app/ComposeEmojiTest.kt | 42 + .../views/chat/ComposeEmojiPicker.desktop.kt | 248 +++ .../src/desktopMain/resources/emoji/emoji.txt | 1879 +++++++++++++++++ .../chat/simplex/app/EmojiPickerDataTest.kt | 55 + apps/multiplatform/product/concepts.md | 2 +- apps/multiplatform/product/gaps.md | 27 + apps/multiplatform/product/views/chat.md | 2 + apps/multiplatform/spec/client/compose.md | 32 +- apps/multiplatform/spec/impact.md | 3 + plans/2026-10-13-compose-emoji-picker.md | 51 + scripts/desktop/generate-emoji-data.py | 44 + 15 files changed, 2429 insertions(+), 7 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt create mode 100644 plans/2026-10-13-compose-emoji-picker.md create mode 100644 scripts/desktop/generate-emoji-data.py diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt new file mode 100644 index 0000000000..0bb9179299 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt @@ -0,0 +1,7 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.* + +// Android keyboards have their own emoji panel +@Composable +actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt new file mode 100644 index 0000000000..ff8482ed7b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt @@ -0,0 +1,14 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.* +import androidx.compose.ui.text.TextRange +import kotlin.math.min + +@Composable +expect fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) + +fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage { + val start = min(selection.min, text.length) + val end = min(selection.max, text.length) + return ComposeMessage(text.replaceRange(start, end, emoji), TextRange(start + emoji.length)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 92ccd92f93..357a010d2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -454,6 +454,7 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } + val emojiPickerShown = rememberSaveable { mutableStateOf(false) } AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } suspend fun fetchAndUpdateLinkPreview(url: String) { @@ -1278,6 +1279,17 @@ fun ComposeView( val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true)) val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) + val emojiEnabled = sendMsgEnabled.value && composeState.value.preview !is ComposePreview.VoicePreview + LaunchedEffect(emojiEnabled) { + if (!emojiEnabled) emojiPickerShown.value = false + } + KeyChangeEffect(emojiPickerShown.value) { + if (!emojiPickerShown.value && emojiEnabled) focusRequester?.requestFocus() + } + + fun onEmoji(emoji: String) { + if (!composeState.value.inProgress) onMessageChange(composeState.value.message.insertingEmoji(emoji)) + } @Composable fun CommandsButton() { @@ -1332,7 +1344,7 @@ fun ComposeView( } @Composable - fun AttachmentAndCommandsButtons() { + fun ComposeActionButtons() { val cInfo = chat.chatInfo Row( Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), @@ -1346,6 +1358,7 @@ fun ComposeView( if (showAttachment) { AttachmentButton() } + ComposeEmojiButton(emojiPickerShown, emojiEnabled, ::onEmoji) } } @@ -1784,7 +1797,7 @@ fun ComposeView( ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect)) Divider() Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentAndCommandsButtons() + ComposeActionButtons() SendMsgView_( disableSendButton = disableSendButton, sendToConnect = { withApi { sendMemberContactInvitation() } } @@ -1839,7 +1852,7 @@ fun ComposeView( ) } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentAndCommandsButtons() + ComposeActionButtons() val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast) else null diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 326be2b05d..9f37a86e52 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -587,6 +587,17 @@ Attach + Emoji + Smileys & emotion + People & body + Animals & nature + Food & drink + Travel & places + Activities + Objects + Symbols + Flags + No emoji found Context icon Cancel image preview Cancel file preview diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt new file mode 100644 index 0000000000..da2fecff60 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt @@ -0,0 +1,42 @@ +package chat.simplex.app + +import androidx.compose.ui.text.TextRange +import chat.simplex.common.views.chat.ComposeMessage +import chat.simplex.common.views.chat.insertingEmoji +import kotlin.test.Test +import kotlin.test.assertEquals + +// use this command for testing: +// ./gradlew desktopTest +class ComposeEmojiTest { + @Test + fun testInsertIntoEmptyMessage() { + assertEquals(ComposeMessage("๐Ÿ˜€", TextRange(2)), ComposeMessage().insertingEmoji("๐Ÿ˜€")) + } + + @Test + fun testInsertAtCursor() { + assertEquals(ComposeMessage("hi๐Ÿ˜€ there", TextRange(4)), ComposeMessage("hi there", TextRange(2)).insertingEmoji("๐Ÿ˜€")) + assertEquals(ComposeMessage("๐Ÿ˜€hi", TextRange(2)), ComposeMessage("hi", TextRange(0)).insertingEmoji("๐Ÿ˜€")) + assertEquals(ComposeMessage("hi๐Ÿ‘", TextRange(4)), ComposeMessage("hi").insertingEmoji("๐Ÿ‘")) + } + + @Test + fun testReplaceSelection() { + assertEquals(ComposeMessage("say โค๏ธ!", TextRange(6)), ComposeMessage("say love!", TextRange(4, 8)).insertingEmoji("โค๏ธ")) + // selection made right to left has start > end + assertEquals(ComposeMessage("say โค๏ธ!", TextRange(6)), ComposeMessage("say love!", TextRange(8, 4)).insertingEmoji("โค๏ธ")) + } + + @Test + fun testInsertAfterEmoji() { + val flag = "๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ" + val withFlag = ComposeMessage(flag) + assertEquals(ComposeMessage(flag + "๐Ÿ‘ฉโ€๐Ÿ’ป", TextRange(flag.length + "๐Ÿ‘ฉโ€๐Ÿ’ป".length)), withFlag.insertingEmoji("๐Ÿ‘ฉโ€๐Ÿ’ป")) + } + + @Test + fun testSelectionBeyondTextIsClamped() { + assertEquals(ComposeMessage("ab๐Ÿ˜€", TextRange(4)), ComposeMessage("ab", TextRange(5)).insertingEmoji("๐Ÿ˜€")) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt new file mode 100644 index 0000000000..65a3bb8c5a --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.* +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.helpers.KeyChangeEffect +import chat.simplex.common.views.helpers.SearchTextField +import chat.simplex.res.* +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.launch + +private const val EMOJI_DATA_RESOURCE = "/emoji/emoji.txt" +private const val EMOJI_GROUP_PREFIX = "@" +private val EMOJI_POPUP_WIDTH = 360.dp +private val EMOJI_POPUP_HEIGHT = 420.dp +private val EMOJI_CELL_SIZE = 38.dp +private val EMOJI_SIZE = 26.dp +private val EMOJI_TAB_SIZE = 32.dp +private val EMOJI_TAB_ICON_SIZE = 20.dp +// the buttons left of the message field overlap by 8 dp, this keeps the emoji button a little apart +private val EMOJI_BUTTON_START_PADDING = 4.dp + +enum class EmojiCategory(val unicodeGroup: String, val icon: String, val title: StringResource) { + Smileys("Smileys & Emotion", "๐Ÿ˜€", MR.strings.emoji_category_smileys), + People("People & Body", "๐Ÿ‘‹", MR.strings.emoji_category_people), + Animals("Animals & Nature", "๐Ÿป", MR.strings.emoji_category_animals), + Food("Food & Drink", "๐Ÿ”", MR.strings.emoji_category_food), + Travel("Travel & Places", "๐Ÿš—", MR.strings.emoji_category_travel), + Activities("Activities", "โšฝ", MR.strings.emoji_category_activities), + Objects("Objects", "๐Ÿ’ก", MR.strings.emoji_category_objects), + Symbols("Symbols", "๐Ÿ”ฃ", MR.strings.emoji_category_symbols), + Flags("Flags", "๐Ÿ", MR.strings.emoji_category_flags), +} + +data class EmojiData(val emoji: String, val name: String) + +data class EmojiSection(val category: EmojiCategory, val emojis: List) + +fun parseEmojiData(lines: Sequence): List { + val sections = mutableListOf() + var emojis = mutableListOf() + for (line in lines) { + if (line.isEmpty()) continue + if (line.startsWith(EMOJI_GROUP_PREFIX)) { + val group = line.removePrefix(EMOJI_GROUP_PREFIX) + val category = EmojiCategory.entries.firstOrNull { it.unicodeGroup == group } + ?: throw IllegalArgumentException("unknown emoji group: $group") + emojis = mutableListOf() + sections.add(EmojiSection(category, emojis)) + } else { + val (emoji, name) = line.split("\t", limit = 2) + emojis.add(EmojiData(emoji, name)) + } + } + return sections +} + +val emojiSections: List by lazy { + val stream = EmojiCategory::class.java.getResourceAsStream(EMOJI_DATA_RESOURCE) + ?: throw IllegalStateException("missing resource $EMOJI_DATA_RESOURCE") + stream.bufferedReader().useLines(::parseEmojiData) +} + +fun searchEmoji(sections: List, query: String): List { + val q = query.trim() + return sections.flatMap { s -> s.emojis.filter { it.name.contains(q, ignoreCase = true) } } +} + +@Composable +actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) { + // the popup opens above the button, so in a short window it must not be taller than the space there + var spaceAbove by remember { mutableStateOf(EMOJI_POPUP_HEIGHT) } + val density = LocalDensity.current + Box(Modifier.padding(start = EMOJI_BUTTON_START_PADDING).onGloballyPositioned { spaceAbove = with(density) { it.positionInWindow().y.toDp() } }) { + EmojiIconButton(painterResource(MR.images.ic_add_reaction), stringResource(MR.strings.icon_descr_emoji), enabled) { + pickerShown.value = !pickerShown.value + } + if (pickerShown.value) { + Popup( + popupPositionProvider = AboveAnchorPositionProvider, + onDismissRequest = { pickerShown.value = false }, + properties = PopupProperties(focusable = true), + // closing on KeyUp, as the window does, so that the same key release does not also close the top modal + onKeyEvent = { + if (it.key == Key.Escape) { + if (it.type == KeyEventType.KeyUp) pickerShown.value = false + true + } else false + } + ) { + EmojiPicker(height = minOf(EMOJI_POPUP_HEIGHT, spaceAbove), onEmoji = onEmoji) + } + } + } +} + +private object AboveAnchorPositionProvider : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val x = if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width + return IntOffset( + x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)), + (anchorBounds.top - popupContentSize.height).coerceAtLeast(0) + ) + } +} + +@Composable +private fun EmojiPicker(height: Dp, onEmoji: (String) -> Unit) { + val sections = remember { emojiSections } + val query = remember { mutableStateOf("") } + val found = remember { derivedStateOf { if (query.value.isBlank()) null else searchEmoji(sections, query.value) } } + val gridState = rememberLazyGridState() + KeyChangeEffect(found.value) { gridState.scrollToItem(0) } + val headerIndexes = remember(sections) { + var index = 0 + sections.map { s -> index.also { index += s.emojis.size + 1 } } + } + Surface( + Modifier.size(EMOJI_POPUP_WIDTH, height), + shape = RoundedCornerShape(12.dp), + elevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.2f)) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(MR.images.ic_search), + contentDescription = null, + Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF).size(22.dp), + tint = MaterialTheme.colors.secondary + ) + SearchTextField(Modifier.weight(1f), alwaysVisible = false) { query.value = it } + } + if (found.value == null) { + EmojiCategoryTabs(sections, headerIndexes, gridState) + } + Divider() + LazyVerticalGrid( + columns = GridCells.Adaptive(EMOJI_CELL_SIZE), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(6.dp) + ) { + val foundEmojis = found.value + if (foundEmojis == null) { + sections.forEach { s -> + sectionHeader(s.category.title) + items(s.emojis) { EmojiCell(it.emoji, onEmoji) } + } + } else if (foundEmojis.isEmpty()) { + sectionHeader(MR.strings.no_emoji_found) + } else { + items(foundEmojis) { EmojiCell(it.emoji, onEmoji) } + } + } + } + } +} + +@Composable +private fun EmojiCategoryTabs(sections: List, headerIndexes: List, gridState: LazyGridState) { + val scope = rememberCoroutineScope() + val selected = remember { + derivedStateOf { headerIndexes.indexOfLast { it <= gridState.firstVisibleItemIndex } } + } + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) { + sections.forEachIndexed { i, s -> + EmojiTab(selected = selected.value == i, onClick = { scope.launch { gridState.scrollToItem(headerIndexes[i]) } }) { + Text(s.category.icon, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_TAB_ICON_SIZE.toSp() }) + } + } + } +} + +@Composable +private fun EmojiTab(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + Modifier + .size(EMOJI_TAB_SIZE) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MaterialTheme.colors.secondary.copy(alpha = 0.15f) else Color.Transparent) + .focusProperties { canFocus = false } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + content() + } +} + +private fun LazyGridScope.sectionHeader(title: StringResource) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + stringResource(title), + Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } +} + +@Composable +private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) { + Box( + Modifier + .size(EMOJI_CELL_SIZE) + .clip(RoundedCornerShape(8.dp)) + // not focusable, so that the search field keeps focus and typing after a click goes to search + .focusProperties { canFocus = false } + .clickable { onEmoji(emoji) }, + contentAlignment = Alignment.Center + ) { + Text(emoji, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_SIZE.toSp() }, maxLines = 1) + } +} + +@Composable +private fun EmojiIconButton(icon: Painter, contentDescription: String, enabled: Boolean, onClick: () -> Unit) { + IconButton(onClick, enabled = enabled) { + Icon( + icon, + contentDescription = contentDescription, + tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + ) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt new file mode 100644 index 0000000000..2a25c14c9f --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt @@ -0,0 +1,1879 @@ +@Smileys & Emotion +๐Ÿ˜€ grinning face +๐Ÿ˜ƒ grinning face with big eyes +๐Ÿ˜„ grinning face with smiling eyes +๐Ÿ˜ beaming face with smiling eyes +๐Ÿ˜† grinning squinting face +๐Ÿ˜… grinning face with sweat +๐Ÿคฃ rolling on the floor laughing +๐Ÿ˜‚ face with tears of joy +๐Ÿ™‚ slightly smiling face +๐Ÿ™ƒ upside-down face +๐Ÿซ  melting face +๐Ÿ˜‰ winking face +๐Ÿ˜Š smiling face with smiling eyes +๐Ÿ˜‡ smiling face with halo +๐Ÿฅฐ smiling face with hearts +๐Ÿ˜ smiling face with heart-eyes +๐Ÿคฉ star-struck +๐Ÿ˜˜ face blowing a kiss +๐Ÿ˜— kissing face +โ˜บ๏ธ smiling face +๐Ÿ˜š kissing face with closed eyes +๐Ÿ˜™ kissing face with smiling eyes +๐Ÿฅฒ smiling face with tear +๐Ÿ˜‹ face savoring food +๐Ÿ˜› face with tongue +๐Ÿ˜œ winking face with tongue +๐Ÿคช zany face +๐Ÿ˜ squinting face with tongue +๐Ÿค‘ money-mouth face +๐Ÿค— smiling face with open hands +๐Ÿคญ face with hand over mouth +๐Ÿซข face with open eyes and hand over mouth +๐Ÿซฃ face with peeking eye +๐Ÿคซ shushing face +๐Ÿค” thinking face +๐Ÿซก saluting face +๐Ÿค zipper-mouth face +๐Ÿคจ face with raised eyebrow +๐Ÿ˜ neutral face +๐Ÿ˜‘ expressionless face +๐Ÿ˜ถ face without mouth +๐Ÿซฅ dotted line face +๐Ÿ˜ถโ€๐ŸŒซ๏ธ face in clouds +๐Ÿ˜ smirking face +๐Ÿ˜’ unamused face +๐Ÿ™„ face with rolling eyes +๐Ÿ˜ฌ grimacing face +๐Ÿ˜ฎโ€๐Ÿ’จ face exhaling +๐Ÿคฅ lying face +๐Ÿซจ shaking face +๐Ÿ˜Œ relieved face +๐Ÿ˜” pensive face +๐Ÿ˜ช sleepy face +๐Ÿคค drooling face +๐Ÿ˜ด sleeping face +๐Ÿ˜ท face with medical mask +๐Ÿค’ face with thermometer +๐Ÿค• face with head-bandage +๐Ÿคข nauseated face +๐Ÿคฎ face vomiting +๐Ÿคง sneezing face +๐Ÿฅต hot face +๐Ÿฅถ cold face +๐Ÿฅด woozy face +๐Ÿ˜ต face with crossed-out eyes +๐Ÿ˜ตโ€๐Ÿ’ซ face with spiral eyes +๐Ÿคฏ exploding head +๐Ÿค  cowboy hat face +๐Ÿฅณ partying face +๐Ÿฅธ disguised face +๐Ÿ˜Ž smiling face with sunglasses +๐Ÿค“ nerd face +๐Ÿง face with monocle +๐Ÿ˜• confused face +๐Ÿซค face with diagonal mouth +๐Ÿ˜Ÿ worried face +๐Ÿ™ slightly frowning face +โ˜น๏ธ frowning face +๐Ÿ˜ฎ face with open mouth +๐Ÿ˜ฏ hushed face +๐Ÿ˜ฒ astonished face +๐Ÿ˜ณ flushed face +๐Ÿฅบ pleading face +๐Ÿฅน face holding back tears +๐Ÿ˜ฆ frowning face with open mouth +๐Ÿ˜ง anguished face +๐Ÿ˜จ fearful face +๐Ÿ˜ฐ anxious face with sweat +๐Ÿ˜ฅ sad but relieved face +๐Ÿ˜ข crying face +๐Ÿ˜ญ loudly crying face +๐Ÿ˜ฑ face screaming in fear +๐Ÿ˜– confounded face +๐Ÿ˜ฃ persevering face +๐Ÿ˜ž disappointed face +๐Ÿ˜“ downcast face with sweat +๐Ÿ˜ฉ weary face +๐Ÿ˜ซ tired face +๐Ÿฅฑ yawning face +๐Ÿ˜ค face with steam from nose +๐Ÿ˜ก enraged face +๐Ÿ˜  angry face +๐Ÿคฌ face with symbols on mouth +๐Ÿ˜ˆ smiling face with horns +๐Ÿ‘ฟ angry face with horns +๐Ÿ’€ skull +โ˜ ๏ธ skull and crossbones +๐Ÿ’ฉ pile of poo +๐Ÿคก clown face +๐Ÿ‘น ogre +๐Ÿ‘บ goblin +๐Ÿ‘ป ghost +๐Ÿ‘ฝ alien +๐Ÿ‘พ alien monster +๐Ÿค– robot +๐Ÿ˜บ grinning cat +๐Ÿ˜ธ grinning cat with smiling eyes +๐Ÿ˜น cat with tears of joy +๐Ÿ˜ป smiling cat with heart-eyes +๐Ÿ˜ผ cat with wry smile +๐Ÿ˜ฝ kissing cat +๐Ÿ™€ weary cat +๐Ÿ˜ฟ crying cat +๐Ÿ˜พ pouting cat +๐Ÿ™ˆ see-no-evil monkey +๐Ÿ™‰ hear-no-evil monkey +๐Ÿ™Š speak-no-evil monkey +๐Ÿ’Œ love letter +๐Ÿ’˜ heart with arrow +๐Ÿ’ heart with ribbon +๐Ÿ’– sparkling heart +๐Ÿ’— growing heart +๐Ÿ’“ beating heart +๐Ÿ’ž revolving hearts +๐Ÿ’• two hearts +๐Ÿ’Ÿ heart decoration +โฃ๏ธ heart exclamation +๐Ÿ’” broken heart +โค๏ธโ€๐Ÿ”ฅ heart on fire +โค๏ธโ€๐Ÿฉน mending heart +โค๏ธ red heart +๐Ÿฉท pink heart +๐Ÿงก orange heart +๐Ÿ’› yellow heart +๐Ÿ’š green heart +๐Ÿ’™ blue heart +๐Ÿฉต light blue heart +๐Ÿ’œ purple heart +๐ŸคŽ brown heart +๐Ÿ–ค black heart +๐Ÿฉถ grey heart +๐Ÿค white heart +๐Ÿ’‹ kiss mark +๐Ÿ’ฏ hundred points +๐Ÿ’ข anger symbol +๐Ÿ’ฅ collision +๐Ÿ’ซ dizzy +๐Ÿ’ฆ sweat droplets +๐Ÿ’จ dashing away +๐Ÿ•ณ๏ธ hole +๐Ÿ’ฌ speech balloon +๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ eye in speech bubble +๐Ÿ—จ๏ธ left speech bubble +๐Ÿ—ฏ๏ธ right anger bubble +๐Ÿ’ญ thought balloon +๐Ÿ’ค ZZZ +@People & Body +๐Ÿ‘‹ waving hand +๐Ÿคš raised back of hand +๐Ÿ–๏ธ hand with fingers splayed +โœ‹ raised hand +๐Ÿ–– vulcan salute +๐Ÿซฑ rightwards hand +๐Ÿซฒ leftwards hand +๐Ÿซณ palm down hand +๐Ÿซด palm up hand +๐Ÿซท leftwards pushing hand +๐Ÿซธ rightwards pushing hand +๐Ÿ‘Œ OK hand +๐ŸคŒ pinched fingers +๐Ÿค pinching hand +โœŒ๏ธ victory hand +๐Ÿคž crossed fingers +๐Ÿซฐ hand with index finger and thumb crossed +๐ŸคŸ love-you gesture +๐Ÿค˜ sign of the horns +๐Ÿค™ call me hand +๐Ÿ‘ˆ backhand index pointing left +๐Ÿ‘‰ backhand index pointing right +๐Ÿ‘† backhand index pointing up +๐Ÿ–• middle finger +๐Ÿ‘‡ backhand index pointing down +โ˜๏ธ index pointing up +๐Ÿซต index pointing at the viewer +๐Ÿ‘ thumbs up +๐Ÿ‘Ž thumbs down +โœŠ raised fist +๐Ÿ‘Š oncoming fist +๐Ÿค› left-facing fist +๐Ÿคœ right-facing fist +๐Ÿ‘ clapping hands +๐Ÿ™Œ raising hands +๐Ÿซถ heart hands +๐Ÿ‘ open hands +๐Ÿคฒ palms up together +๐Ÿค handshake +๐Ÿ™ folded hands +โœ๏ธ writing hand +๐Ÿ’… nail polish +๐Ÿคณ selfie +๐Ÿ’ช flexed biceps +๐Ÿฆพ mechanical arm +๐Ÿฆฟ mechanical leg +๐Ÿฆต leg +๐Ÿฆถ foot +๐Ÿ‘‚ ear +๐Ÿฆป ear with hearing aid +๐Ÿ‘ƒ nose +๐Ÿง  brain +๐Ÿซ€ anatomical heart +๐Ÿซ lungs +๐Ÿฆท tooth +๐Ÿฆด bone +๐Ÿ‘€ eyes +๐Ÿ‘๏ธ eye +๐Ÿ‘… tongue +๐Ÿ‘„ mouth +๐Ÿซฆ biting lip +๐Ÿ‘ถ baby +๐Ÿง’ child +๐Ÿ‘ฆ boy +๐Ÿ‘ง girl +๐Ÿง‘ person +๐Ÿ‘ฑ person: blond hair +๐Ÿ‘จ man +๐Ÿง” person: beard +๐Ÿง”โ€โ™‚๏ธ man: beard +๐Ÿง”โ€โ™€๏ธ woman: beard +๐Ÿ‘จโ€๐Ÿฆฐ man: red hair +๐Ÿ‘จโ€๐Ÿฆฑ man: curly hair +๐Ÿ‘จโ€๐Ÿฆณ man: white hair +๐Ÿ‘จโ€๐Ÿฆฒ man: bald +๐Ÿ‘ฉ woman +๐Ÿ‘ฉโ€๐Ÿฆฐ woman: red hair +๐Ÿง‘โ€๐Ÿฆฐ person: red hair +๐Ÿ‘ฉโ€๐Ÿฆฑ woman: curly hair +๐Ÿง‘โ€๐Ÿฆฑ person: curly hair +๐Ÿ‘ฉโ€๐Ÿฆณ woman: white hair +๐Ÿง‘โ€๐Ÿฆณ person: white hair +๐Ÿ‘ฉโ€๐Ÿฆฒ woman: bald +๐Ÿง‘โ€๐Ÿฆฒ person: bald +๐Ÿ‘ฑโ€โ™€๏ธ woman: blond hair +๐Ÿ‘ฑโ€โ™‚๏ธ man: blond hair +๐Ÿง“ older person +๐Ÿ‘ด old man +๐Ÿ‘ต old woman +๐Ÿ™ person frowning +๐Ÿ™โ€โ™‚๏ธ man frowning +๐Ÿ™โ€โ™€๏ธ woman frowning +๐Ÿ™Ž person pouting +๐Ÿ™Žโ€โ™‚๏ธ man pouting +๐Ÿ™Žโ€โ™€๏ธ woman pouting +๐Ÿ™… person gesturing NO +๐Ÿ™…โ€โ™‚๏ธ man gesturing NO +๐Ÿ™…โ€โ™€๏ธ woman gesturing NO +๐Ÿ™† person gesturing OK +๐Ÿ™†โ€โ™‚๏ธ man gesturing OK +๐Ÿ™†โ€โ™€๏ธ woman gesturing OK +๐Ÿ’ person tipping hand +๐Ÿ’โ€โ™‚๏ธ man tipping hand +๐Ÿ’โ€โ™€๏ธ woman tipping hand +๐Ÿ™‹ person raising hand +๐Ÿ™‹โ€โ™‚๏ธ man raising hand +๐Ÿ™‹โ€โ™€๏ธ woman raising hand +๐Ÿง deaf person +๐Ÿงโ€โ™‚๏ธ deaf man +๐Ÿงโ€โ™€๏ธ deaf woman +๐Ÿ™‡ person bowing +๐Ÿ™‡โ€โ™‚๏ธ man bowing +๐Ÿ™‡โ€โ™€๏ธ woman bowing +๐Ÿคฆ person facepalming +๐Ÿคฆโ€โ™‚๏ธ man facepalming +๐Ÿคฆโ€โ™€๏ธ woman facepalming +๐Ÿคท person shrugging +๐Ÿคทโ€โ™‚๏ธ man shrugging +๐Ÿคทโ€โ™€๏ธ woman shrugging +๐Ÿง‘โ€โš•๏ธ health worker +๐Ÿ‘จโ€โš•๏ธ man health worker +๐Ÿ‘ฉโ€โš•๏ธ woman health worker +๐Ÿง‘โ€๐ŸŽ“ student +๐Ÿ‘จโ€๐ŸŽ“ man student +๐Ÿ‘ฉโ€๐ŸŽ“ woman student +๐Ÿง‘โ€๐Ÿซ teacher +๐Ÿ‘จโ€๐Ÿซ man teacher +๐Ÿ‘ฉโ€๐Ÿซ woman teacher +๐Ÿง‘โ€โš–๏ธ judge +๐Ÿ‘จโ€โš–๏ธ man judge +๐Ÿ‘ฉโ€โš–๏ธ woman judge +๐Ÿง‘โ€๐ŸŒพ farmer +๐Ÿ‘จโ€๐ŸŒพ man farmer +๐Ÿ‘ฉโ€๐ŸŒพ woman farmer +๐Ÿง‘โ€๐Ÿณ cook +๐Ÿ‘จโ€๐Ÿณ man cook +๐Ÿ‘ฉโ€๐Ÿณ woman cook +๐Ÿง‘โ€๐Ÿ”ง mechanic +๐Ÿ‘จโ€๐Ÿ”ง man mechanic +๐Ÿ‘ฉโ€๐Ÿ”ง woman mechanic +๐Ÿง‘โ€๐Ÿญ factory worker +๐Ÿ‘จโ€๐Ÿญ man factory worker +๐Ÿ‘ฉโ€๐Ÿญ woman factory worker +๐Ÿง‘โ€๐Ÿ’ผ office worker +๐Ÿ‘จโ€๐Ÿ’ผ man office worker +๐Ÿ‘ฉโ€๐Ÿ’ผ woman office worker +๐Ÿง‘โ€๐Ÿ”ฌ scientist +๐Ÿ‘จโ€๐Ÿ”ฌ man scientist +๐Ÿ‘ฉโ€๐Ÿ”ฌ woman scientist +๐Ÿง‘โ€๐Ÿ’ป technologist +๐Ÿ‘จโ€๐Ÿ’ป man technologist +๐Ÿ‘ฉโ€๐Ÿ’ป woman technologist +๐Ÿง‘โ€๐ŸŽค singer +๐Ÿ‘จโ€๐ŸŽค man singer +๐Ÿ‘ฉโ€๐ŸŽค woman singer +๐Ÿง‘โ€๐ŸŽจ artist +๐Ÿ‘จโ€๐ŸŽจ man artist +๐Ÿ‘ฉโ€๐ŸŽจ woman artist +๐Ÿง‘โ€โœˆ๏ธ pilot +๐Ÿ‘จโ€โœˆ๏ธ man pilot +๐Ÿ‘ฉโ€โœˆ๏ธ woman pilot +๐Ÿง‘โ€๐Ÿš€ astronaut +๐Ÿ‘จโ€๐Ÿš€ man astronaut +๐Ÿ‘ฉโ€๐Ÿš€ woman astronaut +๐Ÿง‘โ€๐Ÿš’ firefighter +๐Ÿ‘จโ€๐Ÿš’ man firefighter +๐Ÿ‘ฉโ€๐Ÿš’ woman firefighter +๐Ÿ‘ฎ police officer +๐Ÿ‘ฎโ€โ™‚๏ธ man police officer +๐Ÿ‘ฎโ€โ™€๏ธ woman police officer +๐Ÿ•ต๏ธ detective +๐Ÿ•ต๏ธโ€โ™‚๏ธ man detective +๐Ÿ•ต๏ธโ€โ™€๏ธ woman detective +๐Ÿ’‚ guard +๐Ÿ’‚โ€โ™‚๏ธ man guard +๐Ÿ’‚โ€โ™€๏ธ woman guard +๐Ÿฅท ninja +๐Ÿ‘ท construction worker +๐Ÿ‘ทโ€โ™‚๏ธ man construction worker +๐Ÿ‘ทโ€โ™€๏ธ woman construction worker +๐Ÿซ… person with crown +๐Ÿคด prince +๐Ÿ‘ธ princess +๐Ÿ‘ณ person wearing turban +๐Ÿ‘ณโ€โ™‚๏ธ man wearing turban +๐Ÿ‘ณโ€โ™€๏ธ woman wearing turban +๐Ÿ‘ฒ person with skullcap +๐Ÿง• woman with headscarf +๐Ÿคต person in tuxedo +๐Ÿคตโ€โ™‚๏ธ man in tuxedo +๐Ÿคตโ€โ™€๏ธ woman in tuxedo +๐Ÿ‘ฐ person with veil +๐Ÿ‘ฐโ€โ™‚๏ธ man with veil +๐Ÿ‘ฐโ€โ™€๏ธ woman with veil +๐Ÿคฐ pregnant woman +๐Ÿซƒ pregnant man +๐Ÿซ„ pregnant person +๐Ÿคฑ breast-feeding +๐Ÿ‘ฉโ€๐Ÿผ woman feeding baby +๐Ÿ‘จโ€๐Ÿผ man feeding baby +๐Ÿง‘โ€๐Ÿผ person feeding baby +๐Ÿ‘ผ baby angel +๐ŸŽ… Santa Claus +๐Ÿคถ Mrs. Claus +๐Ÿง‘โ€๐ŸŽ„ mx claus +๐Ÿฆธ superhero +๐Ÿฆธโ€โ™‚๏ธ man superhero +๐Ÿฆธโ€โ™€๏ธ woman superhero +๐Ÿฆน supervillain +๐Ÿฆนโ€โ™‚๏ธ man supervillain +๐Ÿฆนโ€โ™€๏ธ woman supervillain +๐Ÿง™ mage +๐Ÿง™โ€โ™‚๏ธ man mage +๐Ÿง™โ€โ™€๏ธ woman mage +๐Ÿงš fairy +๐Ÿงšโ€โ™‚๏ธ man fairy +๐Ÿงšโ€โ™€๏ธ woman fairy +๐Ÿง› vampire +๐Ÿง›โ€โ™‚๏ธ man vampire +๐Ÿง›โ€โ™€๏ธ woman vampire +๐Ÿงœ merperson +๐Ÿงœโ€โ™‚๏ธ merman +๐Ÿงœโ€โ™€๏ธ mermaid +๐Ÿง elf +๐Ÿงโ€โ™‚๏ธ man elf +๐Ÿงโ€โ™€๏ธ woman elf +๐Ÿงž genie +๐Ÿงžโ€โ™‚๏ธ man genie +๐Ÿงžโ€โ™€๏ธ woman genie +๐ŸงŸ zombie +๐ŸงŸโ€โ™‚๏ธ man zombie +๐ŸงŸโ€โ™€๏ธ woman zombie +๐ŸงŒ troll +๐Ÿ’† person getting massage +๐Ÿ’†โ€โ™‚๏ธ man getting massage +๐Ÿ’†โ€โ™€๏ธ woman getting massage +๐Ÿ’‡ person getting haircut +๐Ÿ’‡โ€โ™‚๏ธ man getting haircut +๐Ÿ’‡โ€โ™€๏ธ woman getting haircut +๐Ÿšถ person walking +๐Ÿšถโ€โ™‚๏ธ man walking +๐Ÿšถโ€โ™€๏ธ woman walking +๐Ÿง person standing +๐Ÿงโ€โ™‚๏ธ man standing +๐Ÿงโ€โ™€๏ธ woman standing +๐ŸงŽ person kneeling +๐ŸงŽโ€โ™‚๏ธ man kneeling +๐ŸงŽโ€โ™€๏ธ woman kneeling +๐Ÿง‘โ€๐Ÿฆฏ person with white cane +๐Ÿ‘จโ€๐Ÿฆฏ man with white cane +๐Ÿ‘ฉโ€๐Ÿฆฏ woman with white cane +๐Ÿง‘โ€๐Ÿฆผ person in motorized wheelchair +๐Ÿ‘จโ€๐Ÿฆผ man in motorized wheelchair +๐Ÿ‘ฉโ€๐Ÿฆผ woman in motorized wheelchair +๐Ÿง‘โ€๐Ÿฆฝ person in manual wheelchair +๐Ÿ‘จโ€๐Ÿฆฝ man in manual wheelchair +๐Ÿ‘ฉโ€๐Ÿฆฝ woman in manual wheelchair +๐Ÿƒ person running +๐Ÿƒโ€โ™‚๏ธ man running +๐Ÿƒโ€โ™€๏ธ woman running +๐Ÿ’ƒ woman dancing +๐Ÿ•บ man dancing +๐Ÿ•ด๏ธ person in suit levitating +๐Ÿ‘ฏ people with bunny ears +๐Ÿ‘ฏโ€โ™‚๏ธ men with bunny ears +๐Ÿ‘ฏโ€โ™€๏ธ women with bunny ears +๐Ÿง– person in steamy room +๐Ÿง–โ€โ™‚๏ธ man in steamy room +๐Ÿง–โ€โ™€๏ธ woman in steamy room +๐Ÿง— person climbing +๐Ÿง—โ€โ™‚๏ธ man climbing +๐Ÿง—โ€โ™€๏ธ woman climbing +๐Ÿคบ person fencing +๐Ÿ‡ horse racing +โ›ท๏ธ skier +๐Ÿ‚ snowboarder +๐ŸŒ๏ธ person golfing +๐ŸŒ๏ธโ€โ™‚๏ธ man golfing +๐ŸŒ๏ธโ€โ™€๏ธ woman golfing +๐Ÿ„ person surfing +๐Ÿ„โ€โ™‚๏ธ man surfing +๐Ÿ„โ€โ™€๏ธ woman surfing +๐Ÿšฃ person rowing boat +๐Ÿšฃโ€โ™‚๏ธ man rowing boat +๐Ÿšฃโ€โ™€๏ธ woman rowing boat +๐ŸŠ person swimming +๐ŸŠโ€โ™‚๏ธ man swimming +๐ŸŠโ€โ™€๏ธ woman swimming +โ›น๏ธ person bouncing ball +โ›น๏ธโ€โ™‚๏ธ man bouncing ball +โ›น๏ธโ€โ™€๏ธ woman bouncing ball +๐Ÿ‹๏ธ person lifting weights +๐Ÿ‹๏ธโ€โ™‚๏ธ man lifting weights +๐Ÿ‹๏ธโ€โ™€๏ธ woman lifting weights +๐Ÿšด person biking +๐Ÿšดโ€โ™‚๏ธ man biking +๐Ÿšดโ€โ™€๏ธ woman biking +๐Ÿšต person mountain biking +๐Ÿšตโ€โ™‚๏ธ man mountain biking +๐Ÿšตโ€โ™€๏ธ woman mountain biking +๐Ÿคธ person cartwheeling +๐Ÿคธโ€โ™‚๏ธ man cartwheeling +๐Ÿคธโ€โ™€๏ธ woman cartwheeling +๐Ÿคผ people wrestling +๐Ÿคผโ€โ™‚๏ธ men wrestling +๐Ÿคผโ€โ™€๏ธ women wrestling +๐Ÿคฝ person playing water polo +๐Ÿคฝโ€โ™‚๏ธ man playing water polo +๐Ÿคฝโ€โ™€๏ธ woman playing water polo +๐Ÿคพ person playing handball +๐Ÿคพโ€โ™‚๏ธ man playing handball +๐Ÿคพโ€โ™€๏ธ woman playing handball +๐Ÿคน person juggling +๐Ÿคนโ€โ™‚๏ธ man juggling +๐Ÿคนโ€โ™€๏ธ woman juggling +๐Ÿง˜ person in lotus position +๐Ÿง˜โ€โ™‚๏ธ man in lotus position +๐Ÿง˜โ€โ™€๏ธ woman in lotus position +๐Ÿ›€ person taking bath +๐Ÿ›Œ person in bed +๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ people holding hands +๐Ÿ‘ญ women holding hands +๐Ÿ‘ซ woman and man holding hands +๐Ÿ‘ฌ men holding hands +๐Ÿ’ kiss +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss: woman, man +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss: man, man +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ kiss: woman, woman +๐Ÿ’‘ couple with heart +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ couple with heart: woman, man +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ couple with heart: man, man +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ couple with heart: woman, woman +๐Ÿ‘ช family +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: man, woman, boy +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family: man, woman, girl +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, woman, girl, boy +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, woman, boy, boy +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, woman, girl, girl +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ family: man, man, boy +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง family: man, man, girl +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, man, girl, boy +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, man, boy, boy +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, man, girl, girl +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: woman, woman, boy +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family: woman, woman, girl +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: woman, woman, girl, boy +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: woman, woman, boy, boy +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: woman, woman, girl, girl +๐Ÿ‘จโ€๐Ÿ‘ฆ family: man, boy +๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: man, boy, boy +๐Ÿ‘จโ€๐Ÿ‘ง family: man, girl +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: man, girl, boy +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family: man, girl, girl +๐Ÿ‘ฉโ€๐Ÿ‘ฆ family: woman, boy +๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family: woman, boy, boy +๐Ÿ‘ฉโ€๐Ÿ‘ง family: woman, girl +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family: woman, girl, boy +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family: woman, girl, girl +๐Ÿ—ฃ๏ธ speaking head +๐Ÿ‘ค bust in silhouette +๐Ÿ‘ฅ busts in silhouette +๐Ÿซ‚ people hugging +๐Ÿ‘ฃ footprints +@Animals & Nature +๐Ÿต monkey face +๐Ÿ’ monkey +๐Ÿฆ gorilla +๐Ÿฆง orangutan +๐Ÿถ dog face +๐Ÿ• dog +๐Ÿฆฎ guide dog +๐Ÿ•โ€๐Ÿฆบ service dog +๐Ÿฉ poodle +๐Ÿบ wolf +๐ŸฆŠ fox +๐Ÿฆ raccoon +๐Ÿฑ cat face +๐Ÿˆ cat +๐Ÿˆโ€โฌ› black cat +๐Ÿฆ lion +๐Ÿฏ tiger face +๐Ÿ… tiger +๐Ÿ† leopard +๐Ÿด horse face +๐ŸซŽ moose +๐Ÿซ donkey +๐ŸŽ horse +๐Ÿฆ„ unicorn +๐Ÿฆ“ zebra +๐ŸฆŒ deer +๐Ÿฆฌ bison +๐Ÿฎ cow face +๐Ÿ‚ ox +๐Ÿƒ water buffalo +๐Ÿ„ cow +๐Ÿท pig face +๐Ÿ– pig +๐Ÿ— boar +๐Ÿฝ pig nose +๐Ÿ ram +๐Ÿ‘ ewe +๐Ÿ goat +๐Ÿช camel +๐Ÿซ two-hump camel +๐Ÿฆ™ llama +๐Ÿฆ’ giraffe +๐Ÿ˜ elephant +๐Ÿฆฃ mammoth +๐Ÿฆ rhinoceros +๐Ÿฆ› hippopotamus +๐Ÿญ mouse face +๐Ÿ mouse +๐Ÿ€ rat +๐Ÿน hamster +๐Ÿฐ rabbit face +๐Ÿ‡ rabbit +๐Ÿฟ๏ธ chipmunk +๐Ÿฆซ beaver +๐Ÿฆ” hedgehog +๐Ÿฆ‡ bat +๐Ÿป bear +๐Ÿปโ€โ„๏ธ polar bear +๐Ÿจ koala +๐Ÿผ panda +๐Ÿฆฅ sloth +๐Ÿฆฆ otter +๐Ÿฆจ skunk +๐Ÿฆ˜ kangaroo +๐Ÿฆก badger +๐Ÿพ paw prints +๐Ÿฆƒ turkey +๐Ÿ” chicken +๐Ÿ“ rooster +๐Ÿฃ hatching chick +๐Ÿค baby chick +๐Ÿฅ front-facing baby chick +๐Ÿฆ bird +๐Ÿง penguin +๐Ÿ•Š๏ธ dove +๐Ÿฆ… eagle +๐Ÿฆ† duck +๐Ÿฆข swan +๐Ÿฆ‰ owl +๐Ÿฆค dodo +๐Ÿชถ feather +๐Ÿฆฉ flamingo +๐Ÿฆš peacock +๐Ÿฆœ parrot +๐Ÿชฝ wing +๐Ÿฆโ€โฌ› black bird +๐Ÿชฟ goose +๐Ÿธ frog +๐ŸŠ crocodile +๐Ÿข turtle +๐ŸฆŽ lizard +๐Ÿ snake +๐Ÿฒ dragon face +๐Ÿ‰ dragon +๐Ÿฆ• sauropod +๐Ÿฆ– T-Rex +๐Ÿณ spouting whale +๐Ÿ‹ whale +๐Ÿฌ dolphin +๐Ÿฆญ seal +๐ŸŸ fish +๐Ÿ  tropical fish +๐Ÿก blowfish +๐Ÿฆˆ shark +๐Ÿ™ octopus +๐Ÿš spiral shell +๐Ÿชธ coral +๐Ÿชผ jellyfish +๐ŸŒ snail +๐Ÿฆ‹ butterfly +๐Ÿ› bug +๐Ÿœ ant +๐Ÿ honeybee +๐Ÿชฒ beetle +๐Ÿž lady beetle +๐Ÿฆ— cricket +๐Ÿชณ cockroach +๐Ÿ•ท๏ธ spider +๐Ÿ•ธ๏ธ spider web +๐Ÿฆ‚ scorpion +๐ŸฆŸ mosquito +๐Ÿชฐ fly +๐Ÿชฑ worm +๐Ÿฆ  microbe +๐Ÿ’ bouquet +๐ŸŒธ cherry blossom +๐Ÿ’ฎ white flower +๐Ÿชท lotus +๐Ÿต๏ธ rosette +๐ŸŒน rose +๐Ÿฅ€ wilted flower +๐ŸŒบ hibiscus +๐ŸŒป sunflower +๐ŸŒผ blossom +๐ŸŒท tulip +๐Ÿชป hyacinth +๐ŸŒฑ seedling +๐Ÿชด potted plant +๐ŸŒฒ evergreen tree +๐ŸŒณ deciduous tree +๐ŸŒด palm tree +๐ŸŒต cactus +๐ŸŒพ sheaf of rice +๐ŸŒฟ herb +โ˜˜๏ธ shamrock +๐Ÿ€ four leaf clover +๐Ÿ maple leaf +๐Ÿ‚ fallen leaf +๐Ÿƒ leaf fluttering in wind +๐Ÿชน empty nest +๐Ÿชบ nest with eggs +๐Ÿ„ mushroom +@Food & Drink +๐Ÿ‡ grapes +๐Ÿˆ melon +๐Ÿ‰ watermelon +๐ŸŠ tangerine +๐Ÿ‹ lemon +๐ŸŒ banana +๐Ÿ pineapple +๐Ÿฅญ mango +๐ŸŽ red apple +๐Ÿ green apple +๐Ÿ pear +๐Ÿ‘ peach +๐Ÿ’ cherries +๐Ÿ“ strawberry +๐Ÿซ blueberries +๐Ÿฅ kiwi fruit +๐Ÿ… tomato +๐Ÿซ’ olive +๐Ÿฅฅ coconut +๐Ÿฅ‘ avocado +๐Ÿ† eggplant +๐Ÿฅ” potato +๐Ÿฅ• carrot +๐ŸŒฝ ear of corn +๐ŸŒถ๏ธ hot pepper +๐Ÿซ‘ bell pepper +๐Ÿฅ’ cucumber +๐Ÿฅฌ leafy green +๐Ÿฅฆ broccoli +๐Ÿง„ garlic +๐Ÿง… onion +๐Ÿฅœ peanuts +๐Ÿซ˜ beans +๐ŸŒฐ chestnut +๐Ÿซš ginger root +๐Ÿซ› pea pod +๐Ÿž bread +๐Ÿฅ croissant +๐Ÿฅ– baguette bread +๐Ÿซ“ flatbread +๐Ÿฅจ pretzel +๐Ÿฅฏ bagel +๐Ÿฅž pancakes +๐Ÿง‡ waffle +๐Ÿง€ cheese wedge +๐Ÿ– meat on bone +๐Ÿ— poultry leg +๐Ÿฅฉ cut of meat +๐Ÿฅ“ bacon +๐Ÿ” hamburger +๐ŸŸ french fries +๐Ÿ• pizza +๐ŸŒญ hot dog +๐Ÿฅช sandwich +๐ŸŒฎ taco +๐ŸŒฏ burrito +๐Ÿซ” tamale +๐Ÿฅ™ stuffed flatbread +๐Ÿง† falafel +๐Ÿฅš egg +๐Ÿณ cooking +๐Ÿฅ˜ shallow pan of food +๐Ÿฒ pot of food +๐Ÿซ• fondue +๐Ÿฅฃ bowl with spoon +๐Ÿฅ— green salad +๐Ÿฟ popcorn +๐Ÿงˆ butter +๐Ÿง‚ salt +๐Ÿฅซ canned food +๐Ÿฑ bento box +๐Ÿ˜ rice cracker +๐Ÿ™ rice ball +๐Ÿš cooked rice +๐Ÿ› curry rice +๐Ÿœ steaming bowl +๐Ÿ spaghetti +๐Ÿ  roasted sweet potato +๐Ÿข oden +๐Ÿฃ sushi +๐Ÿค fried shrimp +๐Ÿฅ fish cake with swirl +๐Ÿฅฎ moon cake +๐Ÿก dango +๐ŸฅŸ dumpling +๐Ÿฅ  fortune cookie +๐Ÿฅก takeout box +๐Ÿฆ€ crab +๐Ÿฆž lobster +๐Ÿฆ shrimp +๐Ÿฆ‘ squid +๐Ÿฆช oyster +๐Ÿฆ soft ice cream +๐Ÿง shaved ice +๐Ÿจ ice cream +๐Ÿฉ doughnut +๐Ÿช cookie +๐ŸŽ‚ birthday cake +๐Ÿฐ shortcake +๐Ÿง cupcake +๐Ÿฅง pie +๐Ÿซ chocolate bar +๐Ÿฌ candy +๐Ÿญ lollipop +๐Ÿฎ custard +๐Ÿฏ honey pot +๐Ÿผ baby bottle +๐Ÿฅ› glass of milk +โ˜• hot beverage +๐Ÿซ– teapot +๐Ÿต teacup without handle +๐Ÿถ sake +๐Ÿพ bottle with popping cork +๐Ÿท wine glass +๐Ÿธ cocktail glass +๐Ÿน tropical drink +๐Ÿบ beer mug +๐Ÿป clinking beer mugs +๐Ÿฅ‚ clinking glasses +๐Ÿฅƒ tumbler glass +๐Ÿซ— pouring liquid +๐Ÿฅค cup with straw +๐Ÿง‹ bubble tea +๐Ÿงƒ beverage box +๐Ÿง‰ mate +๐ŸงŠ ice +๐Ÿฅข chopsticks +๐Ÿฝ๏ธ fork and knife with plate +๐Ÿด fork and knife +๐Ÿฅ„ spoon +๐Ÿ”ช kitchen knife +๐Ÿซ™ jar +๐Ÿบ amphora +@Travel & Places +๐ŸŒ globe showing Europe-Africa +๐ŸŒŽ globe showing Americas +๐ŸŒ globe showing Asia-Australia +๐ŸŒ globe with meridians +๐Ÿ—บ๏ธ world map +๐Ÿ—พ map of Japan +๐Ÿงญ compass +๐Ÿ”๏ธ snow-capped mountain +โ›ฐ๏ธ mountain +๐ŸŒ‹ volcano +๐Ÿ—ป mount fuji +๐Ÿ•๏ธ camping +๐Ÿ–๏ธ beach with umbrella +๐Ÿœ๏ธ desert +๐Ÿ๏ธ desert island +๐Ÿž๏ธ national park +๐ŸŸ๏ธ stadium +๐Ÿ›๏ธ classical building +๐Ÿ—๏ธ building construction +๐Ÿงฑ brick +๐Ÿชจ rock +๐Ÿชต wood +๐Ÿ›– hut +๐Ÿ˜๏ธ houses +๐Ÿš๏ธ derelict house +๐Ÿ  house +๐Ÿก house with garden +๐Ÿข office building +๐Ÿฃ Japanese post office +๐Ÿค post office +๐Ÿฅ hospital +๐Ÿฆ bank +๐Ÿจ hotel +๐Ÿฉ love hotel +๐Ÿช convenience store +๐Ÿซ school +๐Ÿฌ department store +๐Ÿญ factory +๐Ÿฏ Japanese castle +๐Ÿฐ castle +๐Ÿ’’ wedding +๐Ÿ—ผ Tokyo tower +๐Ÿ—ฝ Statue of Liberty +โ›ช church +๐Ÿ•Œ mosque +๐Ÿ›• hindu temple +๐Ÿ• synagogue +โ›ฉ๏ธ shinto shrine +๐Ÿ•‹ kaaba +โ›ฒ fountain +โ›บ tent +๐ŸŒ foggy +๐ŸŒƒ night with stars +๐Ÿ™๏ธ cityscape +๐ŸŒ„ sunrise over mountains +๐ŸŒ… sunrise +๐ŸŒ† cityscape at dusk +๐ŸŒ‡ sunset +๐ŸŒ‰ bridge at night +โ™จ๏ธ hot springs +๐ŸŽ  carousel horse +๐Ÿ› playground slide +๐ŸŽก ferris wheel +๐ŸŽข roller coaster +๐Ÿ’ˆ barber pole +๐ŸŽช circus tent +๐Ÿš‚ locomotive +๐Ÿšƒ railway car +๐Ÿš„ high-speed train +๐Ÿš… bullet train +๐Ÿš† train +๐Ÿš‡ metro +๐Ÿšˆ light rail +๐Ÿš‰ station +๐ŸšŠ tram +๐Ÿš monorail +๐Ÿšž mountain railway +๐Ÿš‹ tram car +๐ŸšŒ bus +๐Ÿš oncoming bus +๐ŸšŽ trolleybus +๐Ÿš minibus +๐Ÿš‘ ambulance +๐Ÿš’ fire engine +๐Ÿš“ police car +๐Ÿš” oncoming police car +๐Ÿš• taxi +๐Ÿš– oncoming taxi +๐Ÿš— automobile +๐Ÿš˜ oncoming automobile +๐Ÿš™ sport utility vehicle +๐Ÿ›ป pickup truck +๐Ÿšš delivery truck +๐Ÿš› articulated lorry +๐Ÿšœ tractor +๐ŸŽ๏ธ racing car +๐Ÿ๏ธ motorcycle +๐Ÿ›ต motor scooter +๐Ÿฆฝ manual wheelchair +๐Ÿฆผ motorized wheelchair +๐Ÿ›บ auto rickshaw +๐Ÿšฒ bicycle +๐Ÿ›ด kick scooter +๐Ÿ›น skateboard +๐Ÿ›ผ roller skate +๐Ÿš bus stop +๐Ÿ›ฃ๏ธ motorway +๐Ÿ›ค๏ธ railway track +๐Ÿ›ข๏ธ oil drum +โ›ฝ fuel pump +๐Ÿ›ž wheel +๐Ÿšจ police car light +๐Ÿšฅ horizontal traffic light +๐Ÿšฆ vertical traffic light +๐Ÿ›‘ stop sign +๐Ÿšง construction +โš“ anchor +๐Ÿ›Ÿ ring buoy +โ›ต sailboat +๐Ÿ›ถ canoe +๐Ÿšค speedboat +๐Ÿ›ณ๏ธ passenger ship +โ›ด๏ธ ferry +๐Ÿ›ฅ๏ธ motor boat +๐Ÿšข ship +โœˆ๏ธ airplane +๐Ÿ›ฉ๏ธ small airplane +๐Ÿ›ซ airplane departure +๐Ÿ›ฌ airplane arrival +๐Ÿช‚ parachute +๐Ÿ’บ seat +๐Ÿš helicopter +๐ŸšŸ suspension railway +๐Ÿš  mountain cableway +๐Ÿšก aerial tramway +๐Ÿ›ฐ๏ธ satellite +๐Ÿš€ rocket +๐Ÿ›ธ flying saucer +๐Ÿ›Ž๏ธ bellhop bell +๐Ÿงณ luggage +โŒ› hourglass done +โณ hourglass not done +โŒš watch +โฐ alarm clock +โฑ๏ธ stopwatch +โฒ๏ธ timer clock +๐Ÿ•ฐ๏ธ mantelpiece clock +๐Ÿ•› twelve oโ€™clock +๐Ÿ•ง twelve-thirty +๐Ÿ• one oโ€™clock +๐Ÿ•œ one-thirty +๐Ÿ•‘ two oโ€™clock +๐Ÿ• two-thirty +๐Ÿ•’ three oโ€™clock +๐Ÿ•ž three-thirty +๐Ÿ•“ four oโ€™clock +๐Ÿ•Ÿ four-thirty +๐Ÿ•” five oโ€™clock +๐Ÿ•  five-thirty +๐Ÿ•• six oโ€™clock +๐Ÿ•ก six-thirty +๐Ÿ•– seven oโ€™clock +๐Ÿ•ข seven-thirty +๐Ÿ•— eight oโ€™clock +๐Ÿ•ฃ eight-thirty +๐Ÿ•˜ nine oโ€™clock +๐Ÿ•ค nine-thirty +๐Ÿ•™ ten oโ€™clock +๐Ÿ•ฅ ten-thirty +๐Ÿ•š eleven oโ€™clock +๐Ÿ•ฆ eleven-thirty +๐ŸŒ‘ new moon +๐ŸŒ’ waxing crescent moon +๐ŸŒ“ first quarter moon +๐ŸŒ” waxing gibbous moon +๐ŸŒ• full moon +๐ŸŒ– waning gibbous moon +๐ŸŒ— last quarter moon +๐ŸŒ˜ waning crescent moon +๐ŸŒ™ crescent moon +๐ŸŒš new moon face +๐ŸŒ› first quarter moon face +๐ŸŒœ last quarter moon face +๐ŸŒก๏ธ thermometer +โ˜€๏ธ sun +๐ŸŒ full moon face +๐ŸŒž sun with face +๐Ÿช ringed planet +โญ star +๐ŸŒŸ glowing star +๐ŸŒ  shooting star +๐ŸŒŒ milky way +โ˜๏ธ cloud +โ›… sun behind cloud +โ›ˆ๏ธ cloud with lightning and rain +๐ŸŒค๏ธ sun behind small cloud +๐ŸŒฅ๏ธ sun behind large cloud +๐ŸŒฆ๏ธ sun behind rain cloud +๐ŸŒง๏ธ cloud with rain +๐ŸŒจ๏ธ cloud with snow +๐ŸŒฉ๏ธ cloud with lightning +๐ŸŒช๏ธ tornado +๐ŸŒซ๏ธ fog +๐ŸŒฌ๏ธ wind face +๐ŸŒ€ cyclone +๐ŸŒˆ rainbow +๐ŸŒ‚ closed umbrella +โ˜‚๏ธ umbrella +โ˜” umbrella with rain drops +โ›ฑ๏ธ umbrella on ground +โšก high voltage +โ„๏ธ snowflake +โ˜ƒ๏ธ snowman +โ›„ snowman without snow +โ˜„๏ธ comet +๐Ÿ”ฅ fire +๐Ÿ’ง droplet +๐ŸŒŠ water wave +@Activities +๐ŸŽƒ jack-o-lantern +๐ŸŽ„ Christmas tree +๐ŸŽ† fireworks +๐ŸŽ‡ sparkler +๐Ÿงจ firecracker +โœจ sparkles +๐ŸŽˆ balloon +๐ŸŽ‰ party popper +๐ŸŽŠ confetti ball +๐ŸŽ‹ tanabata tree +๐ŸŽ pine decoration +๐ŸŽŽ Japanese dolls +๐ŸŽ carp streamer +๐ŸŽ wind chime +๐ŸŽ‘ moon viewing ceremony +๐Ÿงง red envelope +๐ŸŽ€ ribbon +๐ŸŽ wrapped gift +๐ŸŽ—๏ธ reminder ribbon +๐ŸŽŸ๏ธ admission tickets +๐ŸŽซ ticket +๐ŸŽ–๏ธ military medal +๐Ÿ† trophy +๐Ÿ… sports medal +๐Ÿฅ‡ 1st place medal +๐Ÿฅˆ 2nd place medal +๐Ÿฅ‰ 3rd place medal +โšฝ soccer ball +โšพ baseball +๐ŸฅŽ softball +๐Ÿ€ basketball +๐Ÿ volleyball +๐Ÿˆ american football +๐Ÿ‰ rugby football +๐ŸŽพ tennis +๐Ÿฅ flying disc +๐ŸŽณ bowling +๐Ÿ cricket game +๐Ÿ‘ field hockey +๐Ÿ’ ice hockey +๐Ÿฅ lacrosse +๐Ÿ“ ping pong +๐Ÿธ badminton +๐ŸฅŠ boxing glove +๐Ÿฅ‹ martial arts uniform +๐Ÿฅ… goal net +โ›ณ flag in hole +โ›ธ๏ธ ice skate +๐ŸŽฃ fishing pole +๐Ÿคฟ diving mask +๐ŸŽฝ running shirt +๐ŸŽฟ skis +๐Ÿ›ท sled +๐ŸฅŒ curling stone +๐ŸŽฏ bullseye +๐Ÿช€ yo-yo +๐Ÿช kite +๐Ÿ”ซ water pistol +๐ŸŽฑ pool 8 ball +๐Ÿ”ฎ crystal ball +๐Ÿช„ magic wand +๐ŸŽฎ video game +๐Ÿ•น๏ธ joystick +๐ŸŽฐ slot machine +๐ŸŽฒ game die +๐Ÿงฉ puzzle piece +๐Ÿงธ teddy bear +๐Ÿช… piรฑata +๐Ÿชฉ mirror ball +๐Ÿช† nesting dolls +โ™ ๏ธ spade suit +โ™ฅ๏ธ heart suit +โ™ฆ๏ธ diamond suit +โ™ฃ๏ธ club suit +โ™Ÿ๏ธ chess pawn +๐Ÿƒ joker +๐Ÿ€„ mahjong red dragon +๐ŸŽด flower playing cards +๐ŸŽญ performing arts +๐Ÿ–ผ๏ธ framed picture +๐ŸŽจ artist palette +๐Ÿงต thread +๐Ÿชก sewing needle +๐Ÿงถ yarn +๐Ÿชข knot +@Objects +๐Ÿ‘“ glasses +๐Ÿ•ถ๏ธ sunglasses +๐Ÿฅฝ goggles +๐Ÿฅผ lab coat +๐Ÿฆบ safety vest +๐Ÿ‘” necktie +๐Ÿ‘• t-shirt +๐Ÿ‘– jeans +๐Ÿงฃ scarf +๐Ÿงค gloves +๐Ÿงฅ coat +๐Ÿงฆ socks +๐Ÿ‘— dress +๐Ÿ‘˜ kimono +๐Ÿฅป sari +๐Ÿฉฑ one-piece swimsuit +๐Ÿฉฒ briefs +๐Ÿฉณ shorts +๐Ÿ‘™ bikini +๐Ÿ‘š womanโ€™s clothes +๐Ÿชญ folding hand fan +๐Ÿ‘› purse +๐Ÿ‘œ handbag +๐Ÿ‘ clutch bag +๐Ÿ›๏ธ shopping bags +๐ŸŽ’ backpack +๐Ÿฉด thong sandal +๐Ÿ‘ž manโ€™s shoe +๐Ÿ‘Ÿ running shoe +๐Ÿฅพ hiking boot +๐Ÿฅฟ flat shoe +๐Ÿ‘  high-heeled shoe +๐Ÿ‘ก womanโ€™s sandal +๐Ÿฉฐ ballet shoes +๐Ÿ‘ข womanโ€™s boot +๐Ÿชฎ hair pick +๐Ÿ‘‘ crown +๐Ÿ‘’ womanโ€™s hat +๐ŸŽฉ top hat +๐ŸŽ“ graduation cap +๐Ÿงข billed cap +๐Ÿช– military helmet +โ›‘๏ธ rescue workerโ€™s helmet +๐Ÿ“ฟ prayer beads +๐Ÿ’„ lipstick +๐Ÿ’ ring +๐Ÿ’Ž gem stone +๐Ÿ”‡ muted speaker +๐Ÿ”ˆ speaker low volume +๐Ÿ”‰ speaker medium volume +๐Ÿ”Š speaker high volume +๐Ÿ“ข loudspeaker +๐Ÿ“ฃ megaphone +๐Ÿ“ฏ postal horn +๐Ÿ”” bell +๐Ÿ”• bell with slash +๐ŸŽผ musical score +๐ŸŽต musical note +๐ŸŽถ musical notes +๐ŸŽ™๏ธ studio microphone +๐ŸŽš๏ธ level slider +๐ŸŽ›๏ธ control knobs +๐ŸŽค microphone +๐ŸŽง headphone +๐Ÿ“ป radio +๐ŸŽท saxophone +๐Ÿช— accordion +๐ŸŽธ guitar +๐ŸŽน musical keyboard +๐ŸŽบ trumpet +๐ŸŽป violin +๐Ÿช• banjo +๐Ÿฅ drum +๐Ÿช˜ long drum +๐Ÿช‡ maracas +๐Ÿชˆ flute +๐Ÿ“ฑ mobile phone +๐Ÿ“ฒ mobile phone with arrow +โ˜Ž๏ธ telephone +๐Ÿ“ž telephone receiver +๐Ÿ“Ÿ pager +๐Ÿ“  fax machine +๐Ÿ”‹ battery +๐Ÿชซ low battery +๐Ÿ”Œ electric plug +๐Ÿ’ป laptop +๐Ÿ–ฅ๏ธ desktop computer +๐Ÿ–จ๏ธ printer +โŒจ๏ธ keyboard +๐Ÿ–ฑ๏ธ computer mouse +๐Ÿ–ฒ๏ธ trackball +๐Ÿ’ฝ computer disk +๐Ÿ’พ floppy disk +๐Ÿ’ฟ optical disk +๐Ÿ“€ dvd +๐Ÿงฎ abacus +๐ŸŽฅ movie camera +๐ŸŽž๏ธ film frames +๐Ÿ“ฝ๏ธ film projector +๐ŸŽฌ clapper board +๐Ÿ“บ television +๐Ÿ“ท camera +๐Ÿ“ธ camera with flash +๐Ÿ“น video camera +๐Ÿ“ผ videocassette +๐Ÿ” magnifying glass tilted left +๐Ÿ”Ž magnifying glass tilted right +๐Ÿ•ฏ๏ธ candle +๐Ÿ’ก light bulb +๐Ÿ”ฆ flashlight +๐Ÿฎ red paper lantern +๐Ÿช” diya lamp +๐Ÿ“” notebook with decorative cover +๐Ÿ“• closed book +๐Ÿ“– open book +๐Ÿ“— green book +๐Ÿ“˜ blue book +๐Ÿ“™ orange book +๐Ÿ“š books +๐Ÿ““ notebook +๐Ÿ“’ ledger +๐Ÿ“ƒ page with curl +๐Ÿ“œ scroll +๐Ÿ“„ page facing up +๐Ÿ“ฐ newspaper +๐Ÿ—ž๏ธ rolled-up newspaper +๐Ÿ“‘ bookmark tabs +๐Ÿ”– bookmark +๐Ÿท๏ธ label +๐Ÿ’ฐ money bag +๐Ÿช™ coin +๐Ÿ’ด yen banknote +๐Ÿ’ต dollar banknote +๐Ÿ’ถ euro banknote +๐Ÿ’ท pound banknote +๐Ÿ’ธ money with wings +๐Ÿ’ณ credit card +๐Ÿงพ receipt +๐Ÿ’น chart increasing with yen +โœ‰๏ธ envelope +๐Ÿ“ง e-mail +๐Ÿ“จ incoming envelope +๐Ÿ“ฉ envelope with arrow +๐Ÿ“ค outbox tray +๐Ÿ“ฅ inbox tray +๐Ÿ“ฆ package +๐Ÿ“ซ closed mailbox with raised flag +๐Ÿ“ช closed mailbox with lowered flag +๐Ÿ“ฌ open mailbox with raised flag +๐Ÿ“ญ open mailbox with lowered flag +๐Ÿ“ฎ postbox +๐Ÿ—ณ๏ธ ballot box with ballot +โœ๏ธ pencil +โœ’๏ธ black nib +๐Ÿ–‹๏ธ fountain pen +๐Ÿ–Š๏ธ pen +๐Ÿ–Œ๏ธ paintbrush +๐Ÿ–๏ธ crayon +๐Ÿ“ memo +๐Ÿ’ผ briefcase +๐Ÿ“ file folder +๐Ÿ“‚ open file folder +๐Ÿ—‚๏ธ card index dividers +๐Ÿ“… calendar +๐Ÿ“† tear-off calendar +๐Ÿ—’๏ธ spiral notepad +๐Ÿ—“๏ธ spiral calendar +๐Ÿ“‡ card index +๐Ÿ“ˆ chart increasing +๐Ÿ“‰ chart decreasing +๐Ÿ“Š bar chart +๐Ÿ“‹ clipboard +๐Ÿ“Œ pushpin +๐Ÿ“ round pushpin +๐Ÿ“Ž paperclip +๐Ÿ–‡๏ธ linked paperclips +๐Ÿ“ straight ruler +๐Ÿ“ triangular ruler +โœ‚๏ธ scissors +๐Ÿ—ƒ๏ธ card file box +๐Ÿ—„๏ธ file cabinet +๐Ÿ—‘๏ธ wastebasket +๐Ÿ”’ locked +๐Ÿ”“ unlocked +๐Ÿ” locked with pen +๐Ÿ” locked with key +๐Ÿ”‘ key +๐Ÿ—๏ธ old key +๐Ÿ”จ hammer +๐Ÿช“ axe +โ›๏ธ pick +โš’๏ธ hammer and pick +๐Ÿ› ๏ธ hammer and wrench +๐Ÿ—ก๏ธ dagger +โš”๏ธ crossed swords +๐Ÿ’ฃ bomb +๐Ÿชƒ boomerang +๐Ÿน bow and arrow +๐Ÿ›ก๏ธ shield +๐Ÿชš carpentry saw +๐Ÿ”ง wrench +๐Ÿช› screwdriver +๐Ÿ”ฉ nut and bolt +โš™๏ธ gear +๐Ÿ—œ๏ธ clamp +โš–๏ธ balance scale +๐Ÿฆฏ white cane +๐Ÿ”— link +โ›“๏ธ chains +๐Ÿช hook +๐Ÿงฐ toolbox +๐Ÿงฒ magnet +๐Ÿชœ ladder +โš—๏ธ alembic +๐Ÿงช test tube +๐Ÿงซ petri dish +๐Ÿงฌ dna +๐Ÿ”ฌ microscope +๐Ÿ”ญ telescope +๐Ÿ“ก satellite antenna +๐Ÿ’‰ syringe +๐Ÿฉธ drop of blood +๐Ÿ’Š pill +๐Ÿฉน adhesive bandage +๐Ÿฉผ crutch +๐Ÿฉบ stethoscope +๐Ÿฉป x-ray +๐Ÿšช door +๐Ÿ›— elevator +๐Ÿชž mirror +๐ŸชŸ window +๐Ÿ›๏ธ bed +๐Ÿ›‹๏ธ couch and lamp +๐Ÿช‘ chair +๐Ÿšฝ toilet +๐Ÿช  plunger +๐Ÿšฟ shower +๐Ÿ› bathtub +๐Ÿชค mouse trap +๐Ÿช’ razor +๐Ÿงด lotion bottle +๐Ÿงท safety pin +๐Ÿงน broom +๐Ÿงบ basket +๐Ÿงป roll of paper +๐Ÿชฃ bucket +๐Ÿงผ soap +๐Ÿซง bubbles +๐Ÿชฅ toothbrush +๐Ÿงฝ sponge +๐Ÿงฏ fire extinguisher +๐Ÿ›’ shopping cart +๐Ÿšฌ cigarette +โšฐ๏ธ coffin +๐Ÿชฆ headstone +โšฑ๏ธ funeral urn +๐Ÿงฟ nazar amulet +๐Ÿชฌ hamsa +๐Ÿ—ฟ moai +๐Ÿชง placard +๐Ÿชช identification card +@Symbols +๐Ÿง ATM sign +๐Ÿšฎ litter in bin sign +๐Ÿšฐ potable water +โ™ฟ wheelchair symbol +๐Ÿšน menโ€™s room +๐Ÿšบ womenโ€™s room +๐Ÿšป restroom +๐Ÿšผ baby symbol +๐Ÿšพ water closet +๐Ÿ›‚ passport control +๐Ÿ›ƒ customs +๐Ÿ›„ baggage claim +๐Ÿ›… left luggage +โš ๏ธ warning +๐Ÿšธ children crossing +โ›” no entry +๐Ÿšซ prohibited +๐Ÿšณ no bicycles +๐Ÿšญ no smoking +๐Ÿšฏ no littering +๐Ÿšฑ non-potable water +๐Ÿšท no pedestrians +๐Ÿ“ต no mobile phones +๐Ÿ”ž no one under eighteen +โ˜ข๏ธ radioactive +โ˜ฃ๏ธ biohazard +โฌ†๏ธ up arrow +โ†—๏ธ up-right arrow +โžก๏ธ right arrow +โ†˜๏ธ down-right arrow +โฌ‡๏ธ down arrow +โ†™๏ธ down-left arrow +โฌ…๏ธ left arrow +โ†–๏ธ up-left arrow +โ†•๏ธ up-down arrow +โ†”๏ธ left-right arrow +โ†ฉ๏ธ right arrow curving left +โ†ช๏ธ left arrow curving right +โคด๏ธ right arrow curving up +โคต๏ธ right arrow curving down +๐Ÿ”ƒ clockwise vertical arrows +๐Ÿ”„ counterclockwise arrows button +๐Ÿ”™ BACK arrow +๐Ÿ”š END arrow +๐Ÿ”› ON! arrow +๐Ÿ”œ SOON arrow +๐Ÿ” TOP arrow +๐Ÿ› place of worship +โš›๏ธ atom symbol +๐Ÿ•‰๏ธ om +โœก๏ธ star of David +โ˜ธ๏ธ wheel of dharma +โ˜ฏ๏ธ yin yang +โœ๏ธ latin cross +โ˜ฆ๏ธ orthodox cross +โ˜ช๏ธ star and crescent +โ˜ฎ๏ธ peace symbol +๐Ÿ•Ž menorah +๐Ÿ”ฏ dotted six-pointed star +๐Ÿชฏ khanda +โ™ˆ Aries +โ™‰ Taurus +โ™Š Gemini +โ™‹ Cancer +โ™Œ Leo +โ™ Virgo +โ™Ž Libra +โ™ Scorpio +โ™ Sagittarius +โ™‘ Capricorn +โ™’ Aquarius +โ™“ Pisces +โ›Ž Ophiuchus +๐Ÿ”€ shuffle tracks button +๐Ÿ” repeat button +๐Ÿ”‚ repeat single button +โ–ถ๏ธ play button +โฉ fast-forward button +โญ๏ธ next track button +โฏ๏ธ play or pause button +โ—€๏ธ reverse button +โช fast reverse button +โฎ๏ธ last track button +๐Ÿ”ผ upwards button +โซ fast up button +๐Ÿ”ฝ downwards button +โฌ fast down button +โธ๏ธ pause button +โน๏ธ stop button +โบ๏ธ record button +โ๏ธ eject button +๐ŸŽฆ cinema +๐Ÿ”… dim button +๐Ÿ”† bright button +๐Ÿ“ถ antenna bars +๐Ÿ›œ wireless +๐Ÿ“ณ vibration mode +๐Ÿ“ด mobile phone off +โ™€๏ธ female sign +โ™‚๏ธ male sign +โšง๏ธ transgender symbol +โœ–๏ธ multiply +โž• plus +โž– minus +โž— divide +๐ŸŸฐ heavy equals sign +โ™พ๏ธ infinity +โ€ผ๏ธ double exclamation mark +โ‰๏ธ exclamation question mark +โ“ red question mark +โ” white question mark +โ• white exclamation mark +โ— red exclamation mark +ใ€ฐ๏ธ wavy dash +๐Ÿ’ฑ currency exchange +๐Ÿ’ฒ heavy dollar sign +โš•๏ธ medical symbol +โ™ป๏ธ recycling symbol +โšœ๏ธ fleur-de-lis +๐Ÿ”ฑ trident emblem +๐Ÿ“› name badge +๐Ÿ”ฐ Japanese symbol for beginner +โญ• hollow red circle +โœ… check mark button +โ˜‘๏ธ check box with check +โœ”๏ธ check mark +โŒ cross mark +โŽ cross mark button +โžฐ curly loop +โžฟ double curly loop +ใ€ฝ๏ธ part alternation mark +โœณ๏ธ eight-spoked asterisk +โœด๏ธ eight-pointed star +โ‡๏ธ sparkle +ยฉ๏ธ copyright +ยฎ๏ธ registered +โ„ข๏ธ trade mark +#๏ธโƒฃ keycap: # +*๏ธโƒฃ keycap: * +0๏ธโƒฃ keycap: 0 +1๏ธโƒฃ keycap: 1 +2๏ธโƒฃ keycap: 2 +3๏ธโƒฃ keycap: 3 +4๏ธโƒฃ keycap: 4 +5๏ธโƒฃ keycap: 5 +6๏ธโƒฃ keycap: 6 +7๏ธโƒฃ keycap: 7 +8๏ธโƒฃ keycap: 8 +9๏ธโƒฃ keycap: 9 +๐Ÿ”Ÿ keycap: 10 +๐Ÿ”  input latin uppercase +๐Ÿ”ก input latin lowercase +๐Ÿ”ข input numbers +๐Ÿ”ฃ input symbols +๐Ÿ”ค input latin letters +๐Ÿ…ฐ๏ธ A button (blood type) +๐Ÿ†Ž AB button (blood type) +๐Ÿ…ฑ๏ธ B button (blood type) +๐Ÿ†‘ CL button +๐Ÿ†’ COOL button +๐Ÿ†“ FREE button +โ„น๏ธ information +๐Ÿ†” ID button +โ“‚๏ธ circled M +๐Ÿ†• NEW button +๐Ÿ†– NG button +๐Ÿ…พ๏ธ O button (blood type) +๐Ÿ†— OK button +๐Ÿ…ฟ๏ธ P button +๐Ÿ†˜ SOS button +๐Ÿ†™ UP! button +๐Ÿ†š VS button +๐Ÿˆ Japanese โ€œhereโ€ button +๐Ÿˆ‚๏ธ Japanese โ€œservice chargeโ€ button +๐Ÿˆท๏ธ Japanese โ€œmonthly amountโ€ button +๐Ÿˆถ Japanese โ€œnot free of chargeโ€ button +๐Ÿˆฏ Japanese โ€œreservedโ€ button +๐Ÿ‰ Japanese โ€œbargainโ€ button +๐Ÿˆน Japanese โ€œdiscountโ€ button +๐Ÿˆš Japanese โ€œfree of chargeโ€ button +๐Ÿˆฒ Japanese โ€œprohibitedโ€ button +๐Ÿ‰‘ Japanese โ€œacceptableโ€ button +๐Ÿˆธ Japanese โ€œapplicationโ€ button +๐Ÿˆด Japanese โ€œpassing gradeโ€ button +๐Ÿˆณ Japanese โ€œvacancyโ€ button +ใŠ—๏ธ Japanese โ€œcongratulationsโ€ button +ใŠ™๏ธ Japanese โ€œsecretโ€ button +๐Ÿˆบ Japanese โ€œopen for businessโ€ button +๐Ÿˆต Japanese โ€œno vacancyโ€ button +๐Ÿ”ด red circle +๐ŸŸ  orange circle +๐ŸŸก yellow circle +๐ŸŸข green circle +๐Ÿ”ต blue circle +๐ŸŸฃ purple circle +๐ŸŸค brown circle +โšซ black circle +โšช white circle +๐ŸŸฅ red square +๐ŸŸง orange square +๐ŸŸจ yellow square +๐ŸŸฉ green square +๐ŸŸฆ blue square +๐ŸŸช purple square +๐ŸŸซ brown square +โฌ› black large square +โฌœ white large square +โ—ผ๏ธ black medium square +โ—ป๏ธ white medium square +โ—พ black medium-small square +โ—ฝ white medium-small square +โ–ช๏ธ black small square +โ–ซ๏ธ white small square +๐Ÿ”ถ large orange diamond +๐Ÿ”ท large blue diamond +๐Ÿ”ธ small orange diamond +๐Ÿ”น small blue diamond +๐Ÿ”บ red triangle pointed up +๐Ÿ”ป red triangle pointed down +๐Ÿ’  diamond with a dot +๐Ÿ”˜ radio button +๐Ÿ”ณ white square button +๐Ÿ”ฒ black square button +@Flags +๐Ÿ chequered flag +๐Ÿšฉ triangular flag +๐ŸŽŒ crossed flags +๐Ÿด black flag +๐Ÿณ๏ธ white flag +๐Ÿณ๏ธโ€๐ŸŒˆ rainbow flag +๐Ÿณ๏ธโ€โšง๏ธ transgender flag +๐Ÿดโ€โ˜ ๏ธ pirate flag +๐Ÿ‡ฆ๐Ÿ‡จ flag: Ascension Island +๐Ÿ‡ฆ๐Ÿ‡ฉ flag: Andorra +๐Ÿ‡ฆ๐Ÿ‡ช flag: United Arab Emirates +๐Ÿ‡ฆ๐Ÿ‡ซ flag: Afghanistan +๐Ÿ‡ฆ๐Ÿ‡ฌ flag: Antigua & Barbuda +๐Ÿ‡ฆ๐Ÿ‡ฎ flag: Anguilla +๐Ÿ‡ฆ๐Ÿ‡ฑ flag: Albania +๐Ÿ‡ฆ๐Ÿ‡ฒ flag: Armenia +๐Ÿ‡ฆ๐Ÿ‡ด flag: Angola +๐Ÿ‡ฆ๐Ÿ‡ถ flag: Antarctica +๐Ÿ‡ฆ๐Ÿ‡ท flag: Argentina +๐Ÿ‡ฆ๐Ÿ‡ธ flag: American Samoa +๐Ÿ‡ฆ๐Ÿ‡น flag: Austria +๐Ÿ‡ฆ๐Ÿ‡บ flag: Australia +๐Ÿ‡ฆ๐Ÿ‡ผ flag: Aruba +๐Ÿ‡ฆ๐Ÿ‡ฝ flag: ร…land Islands +๐Ÿ‡ฆ๐Ÿ‡ฟ flag: Azerbaijan +๐Ÿ‡ง๐Ÿ‡ฆ flag: Bosnia & Herzegovina +๐Ÿ‡ง๐Ÿ‡ง flag: Barbados +๐Ÿ‡ง๐Ÿ‡ฉ flag: Bangladesh +๐Ÿ‡ง๐Ÿ‡ช flag: Belgium +๐Ÿ‡ง๐Ÿ‡ซ flag: Burkina Faso +๐Ÿ‡ง๐Ÿ‡ฌ flag: Bulgaria +๐Ÿ‡ง๐Ÿ‡ญ flag: Bahrain +๐Ÿ‡ง๐Ÿ‡ฎ flag: Burundi +๐Ÿ‡ง๐Ÿ‡ฏ flag: Benin +๐Ÿ‡ง๐Ÿ‡ฑ flag: St. Barthรฉlemy +๐Ÿ‡ง๐Ÿ‡ฒ flag: Bermuda +๐Ÿ‡ง๐Ÿ‡ณ flag: Brunei +๐Ÿ‡ง๐Ÿ‡ด flag: Bolivia +๐Ÿ‡ง๐Ÿ‡ถ flag: Caribbean Netherlands +๐Ÿ‡ง๐Ÿ‡ท flag: Brazil +๐Ÿ‡ง๐Ÿ‡ธ flag: Bahamas +๐Ÿ‡ง๐Ÿ‡น flag: Bhutan +๐Ÿ‡ง๐Ÿ‡ป flag: Bouvet Island +๐Ÿ‡ง๐Ÿ‡ผ flag: Botswana +๐Ÿ‡ง๐Ÿ‡พ flag: Belarus +๐Ÿ‡ง๐Ÿ‡ฟ flag: Belize +๐Ÿ‡จ๐Ÿ‡ฆ flag: Canada +๐Ÿ‡จ๐Ÿ‡จ flag: Cocos (Keeling) Islands +๐Ÿ‡จ๐Ÿ‡ฉ flag: Congo - Kinshasa +๐Ÿ‡จ๐Ÿ‡ซ flag: Central African Republic +๐Ÿ‡จ๐Ÿ‡ฌ flag: Congo - Brazzaville +๐Ÿ‡จ๐Ÿ‡ญ flag: Switzerland +๐Ÿ‡จ๐Ÿ‡ฎ flag: Cรดte dโ€™Ivoire +๐Ÿ‡จ๐Ÿ‡ฐ flag: Cook Islands +๐Ÿ‡จ๐Ÿ‡ฑ flag: Chile +๐Ÿ‡จ๐Ÿ‡ฒ flag: Cameroon +๐Ÿ‡จ๐Ÿ‡ณ flag: China +๐Ÿ‡จ๐Ÿ‡ด flag: Colombia +๐Ÿ‡จ๐Ÿ‡ต flag: Clipperton Island +๐Ÿ‡จ๐Ÿ‡ท flag: Costa Rica +๐Ÿ‡จ๐Ÿ‡บ flag: Cuba +๐Ÿ‡จ๐Ÿ‡ป flag: Cape Verde +๐Ÿ‡จ๐Ÿ‡ผ flag: Curaรงao +๐Ÿ‡จ๐Ÿ‡ฝ flag: Christmas Island +๐Ÿ‡จ๐Ÿ‡พ flag: Cyprus +๐Ÿ‡จ๐Ÿ‡ฟ flag: Czechia +๐Ÿ‡ฉ๐Ÿ‡ช flag: Germany +๐Ÿ‡ฉ๐Ÿ‡ฌ flag: Diego Garcia +๐Ÿ‡ฉ๐Ÿ‡ฏ flag: Djibouti +๐Ÿ‡ฉ๐Ÿ‡ฐ flag: Denmark +๐Ÿ‡ฉ๐Ÿ‡ฒ flag: Dominica +๐Ÿ‡ฉ๐Ÿ‡ด flag: Dominican Republic +๐Ÿ‡ฉ๐Ÿ‡ฟ flag: Algeria +๐Ÿ‡ช๐Ÿ‡ฆ flag: Ceuta & Melilla +๐Ÿ‡ช๐Ÿ‡จ flag: Ecuador +๐Ÿ‡ช๐Ÿ‡ช flag: Estonia +๐Ÿ‡ช๐Ÿ‡ฌ flag: Egypt +๐Ÿ‡ช๐Ÿ‡ญ flag: Western Sahara +๐Ÿ‡ช๐Ÿ‡ท flag: Eritrea +๐Ÿ‡ช๐Ÿ‡ธ flag: Spain +๐Ÿ‡ช๐Ÿ‡น flag: Ethiopia +๐Ÿ‡ช๐Ÿ‡บ flag: European Union +๐Ÿ‡ซ๐Ÿ‡ฎ flag: Finland +๐Ÿ‡ซ๐Ÿ‡ฏ flag: Fiji +๐Ÿ‡ซ๐Ÿ‡ฐ flag: Falkland Islands +๐Ÿ‡ซ๐Ÿ‡ฒ flag: Micronesia +๐Ÿ‡ซ๐Ÿ‡ด flag: Faroe Islands +๐Ÿ‡ซ๐Ÿ‡ท flag: France +๐Ÿ‡ฌ๐Ÿ‡ฆ flag: Gabon +๐Ÿ‡ฌ๐Ÿ‡ง flag: United Kingdom +๐Ÿ‡ฌ๐Ÿ‡ฉ flag: Grenada +๐Ÿ‡ฌ๐Ÿ‡ช flag: Georgia +๐Ÿ‡ฌ๐Ÿ‡ซ flag: French Guiana +๐Ÿ‡ฌ๐Ÿ‡ฌ flag: Guernsey +๐Ÿ‡ฌ๐Ÿ‡ญ flag: Ghana +๐Ÿ‡ฌ๐Ÿ‡ฎ flag: Gibraltar +๐Ÿ‡ฌ๐Ÿ‡ฑ flag: Greenland +๐Ÿ‡ฌ๐Ÿ‡ฒ flag: Gambia +๐Ÿ‡ฌ๐Ÿ‡ณ flag: Guinea +๐Ÿ‡ฌ๐Ÿ‡ต flag: Guadeloupe +๐Ÿ‡ฌ๐Ÿ‡ถ flag: Equatorial Guinea +๐Ÿ‡ฌ๐Ÿ‡ท flag: Greece +๐Ÿ‡ฌ๐Ÿ‡ธ flag: South Georgia & South Sandwich Islands +๐Ÿ‡ฌ๐Ÿ‡น flag: Guatemala +๐Ÿ‡ฌ๐Ÿ‡บ flag: Guam +๐Ÿ‡ฌ๐Ÿ‡ผ flag: Guinea-Bissau +๐Ÿ‡ฌ๐Ÿ‡พ flag: Guyana +๐Ÿ‡ญ๐Ÿ‡ฐ flag: Hong Kong SAR China +๐Ÿ‡ญ๐Ÿ‡ฒ flag: Heard & McDonald Islands +๐Ÿ‡ญ๐Ÿ‡ณ flag: Honduras +๐Ÿ‡ญ๐Ÿ‡ท flag: Croatia +๐Ÿ‡ญ๐Ÿ‡น flag: Haiti +๐Ÿ‡ญ๐Ÿ‡บ flag: Hungary +๐Ÿ‡ฎ๐Ÿ‡จ flag: Canary Islands +๐Ÿ‡ฎ๐Ÿ‡ฉ flag: Indonesia +๐Ÿ‡ฎ๐Ÿ‡ช flag: Ireland +๐Ÿ‡ฎ๐Ÿ‡ฑ flag: Israel +๐Ÿ‡ฎ๐Ÿ‡ฒ flag: Isle of Man +๐Ÿ‡ฎ๐Ÿ‡ณ flag: India +๐Ÿ‡ฎ๐Ÿ‡ด flag: British Indian Ocean Territory +๐Ÿ‡ฎ๐Ÿ‡ถ flag: Iraq +๐Ÿ‡ฎ๐Ÿ‡ท flag: Iran +๐Ÿ‡ฎ๐Ÿ‡ธ flag: Iceland +๐Ÿ‡ฎ๐Ÿ‡น flag: Italy +๐Ÿ‡ฏ๐Ÿ‡ช flag: Jersey +๐Ÿ‡ฏ๐Ÿ‡ฒ flag: Jamaica +๐Ÿ‡ฏ๐Ÿ‡ด flag: Jordan +๐Ÿ‡ฏ๐Ÿ‡ต flag: Japan +๐Ÿ‡ฐ๐Ÿ‡ช flag: Kenya +๐Ÿ‡ฐ๐Ÿ‡ฌ flag: Kyrgyzstan +๐Ÿ‡ฐ๐Ÿ‡ญ flag: Cambodia +๐Ÿ‡ฐ๐Ÿ‡ฎ flag: Kiribati +๐Ÿ‡ฐ๐Ÿ‡ฒ flag: Comoros +๐Ÿ‡ฐ๐Ÿ‡ณ flag: St. Kitts & Nevis +๐Ÿ‡ฐ๐Ÿ‡ต flag: North Korea +๐Ÿ‡ฐ๐Ÿ‡ท flag: South Korea +๐Ÿ‡ฐ๐Ÿ‡ผ flag: Kuwait +๐Ÿ‡ฐ๐Ÿ‡พ flag: Cayman Islands +๐Ÿ‡ฐ๐Ÿ‡ฟ flag: Kazakhstan +๐Ÿ‡ฑ๐Ÿ‡ฆ flag: Laos +๐Ÿ‡ฑ๐Ÿ‡ง flag: Lebanon +๐Ÿ‡ฑ๐Ÿ‡จ flag: St. Lucia +๐Ÿ‡ฑ๐Ÿ‡ฎ flag: Liechtenstein +๐Ÿ‡ฑ๐Ÿ‡ฐ flag: Sri Lanka +๐Ÿ‡ฑ๐Ÿ‡ท flag: Liberia +๐Ÿ‡ฑ๐Ÿ‡ธ flag: Lesotho +๐Ÿ‡ฑ๐Ÿ‡น flag: Lithuania +๐Ÿ‡ฑ๐Ÿ‡บ flag: Luxembourg +๐Ÿ‡ฑ๐Ÿ‡ป flag: Latvia +๐Ÿ‡ฑ๐Ÿ‡พ flag: Libya +๐Ÿ‡ฒ๐Ÿ‡ฆ flag: Morocco +๐Ÿ‡ฒ๐Ÿ‡จ flag: Monaco +๐Ÿ‡ฒ๐Ÿ‡ฉ flag: Moldova +๐Ÿ‡ฒ๐Ÿ‡ช flag: Montenegro +๐Ÿ‡ฒ๐Ÿ‡ซ flag: St. Martin +๐Ÿ‡ฒ๐Ÿ‡ฌ flag: Madagascar +๐Ÿ‡ฒ๐Ÿ‡ญ flag: Marshall Islands +๐Ÿ‡ฒ๐Ÿ‡ฐ flag: North Macedonia +๐Ÿ‡ฒ๐Ÿ‡ฑ flag: Mali +๐Ÿ‡ฒ๐Ÿ‡ฒ flag: Myanmar (Burma) +๐Ÿ‡ฒ๐Ÿ‡ณ flag: Mongolia +๐Ÿ‡ฒ๐Ÿ‡ด flag: Macao SAR China +๐Ÿ‡ฒ๐Ÿ‡ต flag: Northern Mariana Islands +๐Ÿ‡ฒ๐Ÿ‡ถ flag: Martinique +๐Ÿ‡ฒ๐Ÿ‡ท flag: Mauritania +๐Ÿ‡ฒ๐Ÿ‡ธ flag: Montserrat +๐Ÿ‡ฒ๐Ÿ‡น flag: Malta +๐Ÿ‡ฒ๐Ÿ‡บ flag: Mauritius +๐Ÿ‡ฒ๐Ÿ‡ป flag: Maldives +๐Ÿ‡ฒ๐Ÿ‡ผ flag: Malawi +๐Ÿ‡ฒ๐Ÿ‡ฝ flag: Mexico +๐Ÿ‡ฒ๐Ÿ‡พ flag: Malaysia +๐Ÿ‡ฒ๐Ÿ‡ฟ flag: Mozambique +๐Ÿ‡ณ๐Ÿ‡ฆ flag: Namibia +๐Ÿ‡ณ๐Ÿ‡จ flag: New Caledonia +๐Ÿ‡ณ๐Ÿ‡ช flag: Niger +๐Ÿ‡ณ๐Ÿ‡ซ flag: Norfolk Island +๐Ÿ‡ณ๐Ÿ‡ฌ flag: Nigeria +๐Ÿ‡ณ๐Ÿ‡ฎ flag: Nicaragua +๐Ÿ‡ณ๐Ÿ‡ฑ flag: Netherlands +๐Ÿ‡ณ๐Ÿ‡ด flag: Norway +๐Ÿ‡ณ๐Ÿ‡ต flag: Nepal +๐Ÿ‡ณ๐Ÿ‡ท flag: Nauru +๐Ÿ‡ณ๐Ÿ‡บ flag: Niue +๐Ÿ‡ณ๐Ÿ‡ฟ flag: New Zealand +๐Ÿ‡ด๐Ÿ‡ฒ flag: Oman +๐Ÿ‡ต๐Ÿ‡ฆ flag: Panama +๐Ÿ‡ต๐Ÿ‡ช flag: Peru +๐Ÿ‡ต๐Ÿ‡ซ flag: French Polynesia +๐Ÿ‡ต๐Ÿ‡ฌ flag: Papua New Guinea +๐Ÿ‡ต๐Ÿ‡ญ flag: Philippines +๐Ÿ‡ต๐Ÿ‡ฐ flag: Pakistan +๐Ÿ‡ต๐Ÿ‡ฑ flag: Poland +๐Ÿ‡ต๐Ÿ‡ฒ flag: St. Pierre & Miquelon +๐Ÿ‡ต๐Ÿ‡ณ flag: Pitcairn Islands +๐Ÿ‡ต๐Ÿ‡ท flag: Puerto Rico +๐Ÿ‡ต๐Ÿ‡ธ flag: Palestinian Territories +๐Ÿ‡ต๐Ÿ‡น flag: Portugal +๐Ÿ‡ต๐Ÿ‡ผ flag: Palau +๐Ÿ‡ต๐Ÿ‡พ flag: Paraguay +๐Ÿ‡ถ๐Ÿ‡ฆ flag: Qatar +๐Ÿ‡ท๐Ÿ‡ช flag: Rรฉunion +๐Ÿ‡ท๐Ÿ‡ด flag: Romania +๐Ÿ‡ท๐Ÿ‡ธ flag: Serbia +๐Ÿ‡ท๐Ÿ‡บ flag: Russia +๐Ÿ‡ท๐Ÿ‡ผ flag: Rwanda +๐Ÿ‡ธ๐Ÿ‡ฆ flag: Saudi Arabia +๐Ÿ‡ธ๐Ÿ‡ง flag: Solomon Islands +๐Ÿ‡ธ๐Ÿ‡จ flag: Seychelles +๐Ÿ‡ธ๐Ÿ‡ฉ flag: Sudan +๐Ÿ‡ธ๐Ÿ‡ช flag: Sweden +๐Ÿ‡ธ๐Ÿ‡ฌ flag: Singapore +๐Ÿ‡ธ๐Ÿ‡ญ flag: St. Helena +๐Ÿ‡ธ๐Ÿ‡ฎ flag: Slovenia +๐Ÿ‡ธ๐Ÿ‡ฏ flag: Svalbard & Jan Mayen +๐Ÿ‡ธ๐Ÿ‡ฐ flag: Slovakia +๐Ÿ‡ธ๐Ÿ‡ฑ flag: Sierra Leone +๐Ÿ‡ธ๐Ÿ‡ฒ flag: San Marino +๐Ÿ‡ธ๐Ÿ‡ณ flag: Senegal +๐Ÿ‡ธ๐Ÿ‡ด flag: Somalia +๐Ÿ‡ธ๐Ÿ‡ท flag: Suriname +๐Ÿ‡ธ๐Ÿ‡ธ flag: South Sudan +๐Ÿ‡ธ๐Ÿ‡น flag: Sรฃo Tomรฉ & Prรญncipe +๐Ÿ‡ธ๐Ÿ‡ป flag: El Salvador +๐Ÿ‡ธ๐Ÿ‡ฝ flag: Sint Maarten +๐Ÿ‡ธ๐Ÿ‡พ flag: Syria +๐Ÿ‡ธ๐Ÿ‡ฟ flag: Eswatini +๐Ÿ‡น๐Ÿ‡ฆ flag: Tristan da Cunha +๐Ÿ‡น๐Ÿ‡จ flag: Turks & Caicos Islands +๐Ÿ‡น๐Ÿ‡ฉ flag: Chad +๐Ÿ‡น๐Ÿ‡ซ flag: French Southern Territories +๐Ÿ‡น๐Ÿ‡ฌ flag: Togo +๐Ÿ‡น๐Ÿ‡ญ flag: Thailand +๐Ÿ‡น๐Ÿ‡ฏ flag: Tajikistan +๐Ÿ‡น๐Ÿ‡ฐ flag: Tokelau +๐Ÿ‡น๐Ÿ‡ฑ flag: Timor-Leste +๐Ÿ‡น๐Ÿ‡ฒ flag: Turkmenistan +๐Ÿ‡น๐Ÿ‡ณ flag: Tunisia +๐Ÿ‡น๐Ÿ‡ด flag: Tonga +๐Ÿ‡น๐Ÿ‡ท flag: Turkey +๐Ÿ‡น๐Ÿ‡น flag: Trinidad & Tobago +๐Ÿ‡น๐Ÿ‡ป flag: Tuvalu +๐Ÿ‡น๐Ÿ‡ผ flag: Taiwan +๐Ÿ‡น๐Ÿ‡ฟ flag: Tanzania +๐Ÿ‡บ๐Ÿ‡ฆ flag: Ukraine +๐Ÿ‡บ๐Ÿ‡ฌ flag: Uganda +๐Ÿ‡บ๐Ÿ‡ฒ flag: U.S. Outlying Islands +๐Ÿ‡บ๐Ÿ‡ณ flag: United Nations +๐Ÿ‡บ๐Ÿ‡ธ flag: United States +๐Ÿ‡บ๐Ÿ‡พ flag: Uruguay +๐Ÿ‡บ๐Ÿ‡ฟ flag: Uzbekistan +๐Ÿ‡ป๐Ÿ‡ฆ flag: Vatican City +๐Ÿ‡ป๐Ÿ‡จ flag: St. Vincent & Grenadines +๐Ÿ‡ป๐Ÿ‡ช flag: Venezuela +๐Ÿ‡ป๐Ÿ‡ฌ flag: British Virgin Islands +๐Ÿ‡ป๐Ÿ‡ฎ flag: U.S. Virgin Islands +๐Ÿ‡ป๐Ÿ‡ณ flag: Vietnam +๐Ÿ‡ป๐Ÿ‡บ flag: Vanuatu +๐Ÿ‡ผ๐Ÿ‡ซ flag: Wallis & Futuna +๐Ÿ‡ผ๐Ÿ‡ธ flag: Samoa +๐Ÿ‡ฝ๐Ÿ‡ฐ flag: Kosovo +๐Ÿ‡พ๐Ÿ‡ช flag: Yemen +๐Ÿ‡พ๐Ÿ‡น flag: Mayotte +๐Ÿ‡ฟ๐Ÿ‡ฆ flag: South Africa +๐Ÿ‡ฟ๐Ÿ‡ฒ flag: Zambia +๐Ÿ‡ฟ๐Ÿ‡ผ flag: Zimbabwe +๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ flag: England +๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ flag: Scotland +๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ flag: Wales diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt new file mode 100644 index 0000000000..c5860ed9de --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt @@ -0,0 +1,55 @@ +package chat.simplex.app + +import chat.simplex.common.views.chat.* +import kotlin.test.* + +// use this command for testing: +// ./gradlew desktopTest +class EmojiPickerDataTest { + private val skinToneModifiers = 0x1F3FB..0x1F3FF + + @Test + fun testBundledDataHasAllCategoriesInOrder() { + assertEquals(EmojiCategory.entries.toList(), emojiSections.map { it.category }) + emojiSections.forEach { assertTrue(it.emojis.isNotEmpty(), "empty category ${it.category}") } + assertEquals(1870, emojiSections.sumOf { it.emojis.size }) + } + + @Test + fun testBundledDataEntries() { + val all = emojiSections.flatMap { it.emojis } + assertEquals(all.size, all.map { it.emoji }.toSet().size, "duplicate emoji") + all.forEach { + assertTrue(it.emoji.isNotEmpty() && it.name.isNotBlank(), "bad entry $it") + assertFalse(it.emoji.codePoints().anyMatch { cp -> cp in skinToneModifiers }, "skin tone variant ${it.name}") + } + assertEquals(EmojiData("๐Ÿ˜€", "grinning face"), emojiSections.first().emojis.first()) + assertEquals(EmojiData("๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ", "flag: Wales"), emojiSections.last().emojis.last()) + } + + @Test + fun testParseEmojiData() { + val sections = parseEmojiData(sequenceOf("@Smileys & Emotion", "๐Ÿ˜€\tgrinning face", "", "@Flags", "๐Ÿ\tchequered flag", "#๏ธโƒฃ\tkeycap: #")) + assertEquals( + listOf( + EmojiSection(EmojiCategory.Smileys, listOf(EmojiData("๐Ÿ˜€", "grinning face"))), + EmojiSection(EmojiCategory.Flags, listOf(EmojiData("๐Ÿ", "chequered flag"), EmojiData("#๏ธโƒฃ", "keycap: #"))), + ), + sections + ) + } + + @Test + fun testParseUnknownGroupFails() { + assertFailsWith { parseEmojiData(sequenceOf("@Component", "๐Ÿป\tlight skin tone")) } + } + + @Test + fun testSearchEmoji() { + assertEquals(listOf("โค๏ธ"), searchEmoji(emojiSections, "red heart").map { it.emoji }) + assertEquals(searchEmoji(emojiSections, "red heart"), searchEmoji(emojiSections, " RED Heart ")) + val flags = searchEmoji(emojiSections, "flag: ") + assertTrue(flags.size > 250, "found ${flags.size} flags") + assertTrue(searchEmoji(emojiSections, "no such emoji name").isEmpty()) + } +} diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index d29ac5c2e5..05a637b4bd 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -22,7 +22,7 @@ This document provides a structured mapping between product-level concepts, thei | PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) | | PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | | PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | -| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | +| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt`, `ComposeEmojiPicker.kt` | `Controller.hs` (`APISendMessages`) | | PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) | | PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) | | PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index aae24bdca8..1cfff3b220 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -13,6 +13,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) 6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) +8. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-08-desktop-emoji-picker-without-skin-tones-and-localized-search) --- @@ -289,3 +290,29 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self - GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. - The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. + +--- + +## GAP-08: Desktop: Emoji Picker Without Skin Tones and Localized Search + +**Severity:** Low +**Category:** Feature / Platform +**Platform:** Desktop only + +### Description + +The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt` +- `scripts/desktop/generate-emoji-data.py` (`../../scripts/desktop/` relative to `apps/multiplatform/`) -- drops skin tone variants + +### Impact + +Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing. + +### Recommendation + +1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker. +2. Add localized names (CLDR annotations) to the generated data for search. diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index 7862574f04..c920e40146 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -95,6 +95,7 @@ Bottom input area for composing messages: |---|---| | Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support | | Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file | +| Emoji button | Desktop only (Android keyboards have their own emoji panel). Opens a popup with search and categories; a picked emoji is inserted at the cursor and the picker stays open. With text selected the emoji goes after it, as the text field drops its selection when the picker takes focus. Picked emoji are not remembered, for privacy. Esc, a click outside or the button closes it | | Send button | Sends message; changes to checkmark for reports; animated size/alpha | | Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview | | Live message button | Start/update live typing message (if `liveMessageAlertShown`) | @@ -132,4 +133,5 @@ When `timedMessageAllowed` is true, compose bar includes a timer icon for settin | `ChatView.kt` | `views/chat/ChatView.kt` | | `ComposeView.kt` | `views/chat/ComposeView.kt` | | `SendMsgView.kt` | `views/chat/SendMsgView.kt` | +| `ComposeEmojiPicker.kt` | `views/chat/ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | | Chat item views | `views/chat/item/*.kt` | diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md index 241dcf667b..e7f879f5b0 100644 --- a/apps/multiplatform/spec/client/compose.md +++ b/apps/multiplatform/spec/client/compose.md @@ -14,6 +14,7 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView 6. [Attachment Handling](#6-attachment-handling) 7. [Draft Persistence](#7-draft-persistence) 8. [Source Files](#8-source-files) +9. [Emoji Picker](#9-emoji-picker) --- @@ -41,9 +42,10 @@ ComposeView | |-- ComposeImageView [media thumbnails] | |-- ComposeVoiceView [voice recording waveform] | +-- ComposeFileView [file name display] -|-- AttachmentAndCommandsButtons +|-- ComposeActionButtons | |-- CommandsButton [bot commands "//"] -| +-- AttachmentButton [paperclip icon] +| |-- AttachmentButton [paperclip icon] +| +-- ComposeEmojiButton [desktop emoji picker popup] +-- SendMsgView |-- PlatformTextField [multiline text input] |-- DeleteTextButton [clear text, shown on long text] @@ -348,7 +350,7 @@ When `privacyLinkPreviews` is enabled and the message contains a URL: ## 7. Draft Persistence -**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`) +**Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`) Controlled by the `privacySaveLastDraft` preference. @@ -397,3 +399,27 @@ When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the d | `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | | `ComposeContextProfilePickerView.kt` | Profile picker in compose context | | `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | +| `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion | + +--- + + + +## 9. Emoji Picker + +**Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt) + +Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel. + +An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text. + +[`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`. + +| Function | Behavior | +|---|---| +| [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button | +| [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences | + +Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@` lines followed by `\t` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively. + +[GAP] No skin tone selection, and search uses English emoji names only. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 7e0fb181d8..20053696d9 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -111,6 +111,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI โ€” most messaging features | | `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition โ€” send path for all messages | | `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | +| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion | | `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI | | `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area | | `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | @@ -365,6 +366,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` | `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) | | `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions | | `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions | +| `views/chat/ComposeEmojiPicker.android.kt` | PC4 | Low | Empty emoji button actual (no picker on Android) | | `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions | | `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions | | `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions | @@ -439,6 +441,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call | | `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) | | `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) | +| `views/chat/ComposeEmojiPicker.desktop.kt` | PC4 | Low | Desktop emoji picker popup (data from `resources/emoji/emoji.txt`, search) | | `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions | | `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering | | `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering | diff --git a/plans/2026-10-13-compose-emoji-picker.md b/plans/2026-10-13-compose-emoji-picker.md new file mode 100644 index 0000000000..ded2a0ce47 --- /dev/null +++ b/plans/2026-10-13-compose-emoji-picker.md @@ -0,0 +1,51 @@ +# Desktop: emoji picker in the compose field + +## Problem + +The desktop compose field has no way to choose an emoji: Linux has no system emoji picker, and on Windows and macOS the system pickers are shortcuts most users do not know. Android keyboards have their own emoji panel, so Android is not changed. + +## Solution + +On desktop, an emoji button next to the attachment button opens a popup emoji picker: search field, category tabs, grid of emoji. A picked emoji is inserted at the cursor and the cursor moves after it; the picker stays open, so several emoji can be picked in a row. Esc, a click outside or the button closes it, and focus returns to the text field. The text field collapses its selection to the end when the popup takes focus, so with text selected the emoji goes after it. + +No protocol or core changes: picked emoji are ordinary message text. + +## Technical design + +### Common (`views/chat/ComposeEmojiPicker.kt`) + +- `expect fun ComposeEmojiButton(pickerShown, enabled, onEmoji)`. It sits in `ComposeActionButtons` (renamed from `AttachmentAndCommandsButtons`) after the attachment button. The Android actual is empty. +- `fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage` replaces `selection` (clamped to the text) with the emoji and puts the cursor after it. `ComposeView.onEmoji` passes the result to `onMessageChange`, so markdown parsing, link sanitizing and the large-emoji font apply as for typed text. Ignored while a message is being sent. + +`ComposeView` owns `emojiPickerShown` (`rememberSaveable`). It is closed when sending becomes disabled or a voice message is recorded (`emojiEnabled`). When it closes, focus returns to the text field through the existing `focusRequester`. + +### Desktop (`ComposeEmojiPicker.desktop.kt`) + +- Button: same look as the attachment button. The row keeps its `spacedBy(-8.dp)` overlap; the emoji button adds `EMOJI_BUTTON_START_PADDING` (4 dp), so its centre is 44 dp from the attachment button. +- Data: `desktopMain/resources/emoji/emoji.txt`, generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0, the version covered by the bundled NotoColorEmoji 2.038 (all 1870 sequences checked against the font cmap). Fully-qualified emoji only, without the Component group and without skin tone variants. Format: `@` lines, then `\t` lines. Parsed once, lazily (`emojiSections`). +- `EmojiCategory` maps Unicode group names to a tab emoji and a localized title. +- Popup: focusable `Popup` above the button (`AboveAnchorPositionProvider`), 360ร—420 dp, or shorter when the window has less space above the button. Search field (auto-focused), category tabs, then a `LazyVerticalGrid` with section headers; tabs and cells are not focusable, so typing after a click still goes to search. Esc (handled on key release, as the window does, so it does not also close the top modal), a click outside, or the button closes it. +- Search: case-insensitive substring of the English Unicode name (`searchEmoji`). +- No recently used list: storing picked emoji in preferences would keep a trace of what the user wrote outside the encrypted database. +- Rendering uses `EmojiFont` (Noto on Windows/Linux, system font on macOS), as emoji-only messages do. + +## Affected docs + +- `product/views/chat.md` โ€” Compose Bar table: emoji button. +- `spec/client/compose.md` โ€” emoji picker functions. +- `spec/impact.md` โ€” new source files. +- `product/concepts.md` โ€” PC4 source list. + +## Not included + +- Android emoji picker: the keyboard's emoji panel covers it. An in-app panel was built and tested in simulation, then dropped; it would also need its own recents disabled for privacy. +- Skin tone selection. +- Emoji shortcode autocomplete (`:smile:`). +- Localized emoji names for search. +- Custom (image) emoji. + +## Verification + +- Unit tests: `insertingEmoji` (empty, cursor, selection, surrogate pairs); desktop data loads, every group maps to a category, no skin tone modifiers, search. +- The real desktop app run under Xvfb with screenshots and a screen recording: button in a group compose bar and its spacing, insert at cursor, several picks, search, no results, category tabs, close by Esc / outside click / button with focus returned, emoji-only message in large font, light and dark themes, editing, live messages, chat switching, small window, large font, right-to-left layout. +- Android: `compileDebugKotlinAndroid` passes; the compose bar is unchanged from before. diff --git a/scripts/desktop/generate-emoji-data.py b/scripts/desktop/generate-emoji-data.py new file mode 100644 index 0000000000..47f18adeb0 --- /dev/null +++ b/scripts/desktop/generate-emoji-data.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# Generates the desktop emoji picker data from Unicode emoji-test.txt. +# Emoji version must not exceed the bundled NotoColorEmoji font (2.038 covers Emoji 15.0): +# curl -O https://unicode.org/Public/emoji/15.0/emoji-test.txt +# python3 scripts/desktop/generate-emoji-data.py emoji-test.txt apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt +# Output: "@" lines followed by "\t" lines, in CLDR order. + +import sys + +SKIPPED_GROUPS = {"Component"} +SKIN_TONE_MODIFIERS = {0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF} + + +def parse(lines): + group = None + for line in lines: + line = line.rstrip("\n") + if line.startswith("# group: "): + group = line[len("# group: "):] + if group not in SKIPPED_GROUPS: + yield "@" + group + elif line and not line.startswith("#") and group not in SKIPPED_GROUPS: + codes, rest = line.split(";", 1) + status, comment = rest.split("#", 1) + if status.strip() != "fully-qualified": + continue + code_points = [int(c, 16) for c in codes.split()] + if SKIN_TONE_MODIFIERS.intersection(code_points): + continue + # comment is " E " + name = comment.strip().split(" ", 2)[2] + yield "".join(map(chr, code_points)) + "\t" + name + + +def main(): + src, dst = sys.argv[1], sys.argv[2] + with open(src, encoding="utf-8") as f: + out = list(parse(f)) + with open(dst, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(out) + "\n") + + +if __name__ == "__main__": + main() From 7c5b6bb3d74b247ee08071c717e59a804da79b93 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:33:50 +0000 Subject: [PATCH 2/3] ci: test the iOS app in the simulator on request, with an optional Claude session (#7737) --- .github/workflows/ios-sim.yml | 348 ++++++++++++++++++ scripts/ci/ios-sim/README.md | 57 +++ scripts/ci/ios-sim/avatar.swift | 87 +++++ scripts/ci/ios-sim/backend.sh | 477 +++++++++++++++++++++++++ scripts/ci/ios-sim/build-backend.sh | 53 +++ scripts/ci/ios-sim/build-core.sh | 66 ++++ scripts/ci/ios-sim/claude-guard.py | 25 ++ scripts/ci/ios-sim/claude-prompt.md | 59 +++ scripts/ci/ios-sim/claude-scope | 8 + scripts/ci/ios-sim/claude-test.sh | 143 ++++++++ scripts/ci/ios-sim/claude-usage.sh | 42 +++ scripts/ci/ios-sim/device-test-plan.md | 9 + scripts/ci/ios-sim/earlier-run.sh | 20 ++ scripts/ci/ios-sim/install-axe.sh | 21 ++ scripts/ci/ios-sim/names-resolver.py | 116 ++++++ scripts/ci/ios-sim/prepare-libs.sh | 25 ++ scripts/ci/ios-sim/smoke-test.sh | 124 +++++++ scripts/ci/ios-sim/ui | 237 ++++++++++++ scripts/ci/ios-sim/use-macos26-sdk.sh | 12 + 19 files changed, 1929 insertions(+) create mode 100644 .github/workflows/ios-sim.yml create mode 100644 scripts/ci/ios-sim/README.md create mode 100644 scripts/ci/ios-sim/avatar.swift create mode 100755 scripts/ci/ios-sim/backend.sh create mode 100755 scripts/ci/ios-sim/build-backend.sh create mode 100755 scripts/ci/ios-sim/build-core.sh create mode 100755 scripts/ci/ios-sim/claude-guard.py create mode 100644 scripts/ci/ios-sim/claude-prompt.md create mode 100644 scripts/ci/ios-sim/claude-scope create mode 100755 scripts/ci/ios-sim/claude-test.sh create mode 100755 scripts/ci/ios-sim/claude-usage.sh create mode 100644 scripts/ci/ios-sim/device-test-plan.md create mode 100755 scripts/ci/ios-sim/earlier-run.sh create mode 100755 scripts/ci/ios-sim/install-axe.sh create mode 100755 scripts/ci/ios-sim/names-resolver.py create mode 100755 scripts/ci/ios-sim/prepare-libs.sh create mode 100755 scripts/ci/ios-sim/smoke-test.sh create mode 100755 scripts/ci/ios-sim/ui create mode 100755 scripts/ci/ios-sim/use-macos26-sdk.sh diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml new file mode 100644 index 0000000000..5d44162369 --- /dev/null +++ b/.github/workflows/ios-sim.yml @@ -0,0 +1,348 @@ +name: ios-sim + +on: + push: + branches: + - '**' + # a squash merge's message lists the merged commits' subjects, with their tags + - '!master' + - '!stable' + workflow_dispatch: + inputs: + claude: + description: Also run the Claude session + type: boolean + default: false + backend: + description: Start the test backend + type: boolean + default: false + +permissions: + contents: read + actions: read + +concurrency: + # a push that asks for no run must not cancel one that does + group: ios-sim-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]')) && 'requested' || github.run_id }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + CORE_PKG: pkg-ios-aarch64-swift-json.zip + BACKEND_PKG: ios-sim-backend.zip + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + # newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + +jobs: + plan: + name: find earlier builds + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.head_commit.message, '[app-ci]') || + contains(github.event.head_commit.message, '[app-ci:ios]') || + contains(github.event.head_commit.message, '[claude-test]') || + contains(github.event.head_commit.message, '[claude-test:ios]') + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + core: ${{ steps.names.outputs.core }} + backend: ${{ steps.names.outputs.backend }} + core-run: ${{ steps.core.outputs.run-id }} + backend-run: ${{ steps.backend.outputs.run-id }} + claude: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} + start-backend: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded + - name: Artifact names + id: names + run: | + echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find core library + id: core + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}" + + - name: Find backend + id: backend + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}" + + core: + name: core library for the iOS simulator + needs: plan + if: needs.plan.outputs.core-run == '' + runs-on: macos-latest + timeout-minutes: 240 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Show runner + run: | + sw_vers + sysctl -n hw.ncpu hw.memsize + df -h / + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + # without ~/.cabal, cabal 3.10 keeps its store in XDG directories, not where setup-haskell reports it + - name: Use ~/.cabal for cabal + run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" + + - name: Setup Haskell + id: haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Restore cabal store + id: cabal-cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ steps.haskell.outputs.cabal-store }} + dist-newstyle + key: ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} + restore-keys: | + ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- + ios-sim-core-store-ghc${{ env.GHC_VER }}- + + - name: Install libraries + run: brew install openssl@3 libffi gmp + + - name: Build core library + id: build + run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }} + + - name: Save cabal store + if: ${{ !cancelled() }} + uses: actions/cache/save@v4 + with: + path: | + ${{ steps.haskell.outputs.cabal-store }} + dist-newstyle + key: ${{ steps.cabal-cache.outputs.cache-primary-key }} + + - name: Patch core library for the simulator + run: scripts/ci/ios-sim/prepare-libs.sh + + - name: Upload core library + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/Downloads/${{ env.CORE_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + backend: + name: test backend for the app + needs: plan + if: needs.plan.outputs.start-backend == 'true' && needs.plan.outputs.backend-run == '' + runs-on: macos-latest + timeout-minutes: 240 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Show runner + run: | + sw_vers + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Use ~/.cabal for cabal + run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" + + - name: Setup Haskell + id: haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Restore cabal store + id: cabal-cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ steps.haskell.outputs.cabal-store }} + dist-newstyle + key: ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} + restore-keys: | + ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- + ios-sim-backend-store-ghc${{ env.GHC_VER }}- + + - name: Build backend + run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }} + + - name: Save cabal store + if: ${{ !cancelled() }} + uses: actions/cache/save@v4 + with: + path: | + ${{ steps.haskell.outputs.cabal-store }} + dist-newstyle + key: ${{ steps.cabal-cache.outputs.cache-primary-key }} + + - name: Upload backend + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads/${{ env.BACKEND_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + app: + name: app in the iOS simulator + needs: [plan, core, backend] + # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + runs-on: macos-latest + environment: + name: ${{ needs.plan.outputs.claude == 'true' && 'app-sim-claude' || '' }} + deployment: false + timeout-minutes: 330 + env: + CLAUDE_RUN: ${{ needs.plan.outputs.claude }} + START_BACKEND: ${{ needs.plan.outputs.start-backend }} + steps: + # the job later holds the Claude token, so the checkout's GitHub token is not left in .git/config + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Show runner + run: | + sw_vers + df -h / + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + xcrun simctl list runtimes + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Download core library + uses: actions/download-artifact@v4 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Prepare libraries + run: | + scripts/ci/ios-sim/prepare-libs.sh + scripts/ios/update-pbxproj.sh + + - name: Download backend + if: env.START_BACKEND == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + if: env.START_BACKEND == 'true' + timeout-minutes: 30 + run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + - name: Build app + run: | + xcodebuild build -quiet \ + -project apps/ios/SimpleX.xcodeproj \ + -scheme "SimpleX (iOS)" \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -onlyUsePackageVersionsFromResolvedFile \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + ARCHS=arm64 \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + PROVISIONING_PROFILE_SPECIFIER= + + - name: Install AXe + run: scripts/ci/ios-sim/install-axe.sh + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" + + - name: Install Claude Code + if: env.CLAUDE_RUN == 'true' + run: | + npm install -g @anthropic-ai/claude-code@2.1.300 + claude --version + + - name: Run the device test plan with Claude + if: env.CLAUDE_RUN == 'true' + timeout-minutes: 240 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + BACKEND_DIR: ${{ env.START_BACKEND == 'true' && format('{0}/backend', runner.temp) || '' }} + run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" + + - name: Report Claude usage + if: ${{ always() && env.CLAUDE_RUN == 'true' }} + run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" + + - name: Stop test backend + if: ${{ !cancelled() && env.START_BACKEND == 'true' }} + run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ios-sim-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && env.START_BACKEND == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ios-sim-backend-logs + path: | + ${{ runner.temp }}/backend/*.log + ${{ runner.temp }}/backend/seed.md + retention-days: 3 + if-no-files-found: warn + + - name: Upload Claude test results + if: ${{ always() && env.CLAUDE_RUN == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ios-sim-claude-test + path: ${{ runner.temp }}/claude-test + retention-days: 14 + if-no-files-found: warn diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md new file mode 100644 index 0000000000..dd2c37c030 --- /dev/null +++ b/scripts/ci/ios-sim/README.md @@ -0,0 +1,57 @@ +# iOS app in the simulator on GitHub's runners + +`.github/workflows/ios-sim.yml` builds the core library and the iOS app from the branch's code on a macOS runner, starts the app in an iPhone 17 simulator, and drives it through onboarding to the chat list with a scripted smoke test. On request it also runs a Claude session that follows a device test plan, and a test backend (relay, directory, names server, contacts) for tests that need servers and peers. + +## Running it + +The workflow runs only when asked, on any branch: + +- **Dispatch** it from the Actions tab (workflow "ios-sim", "Run workflow"), with the inputs `claude` (also run the Claude session) and `backend` (also start the test backend), both off by default. +- **Push** a commit whose message carries a tag: `[app-ci]` runs the scripted tests of both apps (iOS and desktop), `[app-ci:ios]` only this one; `[claude-test]` (both apps) or `[claude-test:ios]` runs the scripted test and then the Claude session; add `[ci-backend]` to build and start the test backend. Only the pushed head commit counts, and a tag matches anywhere in its message, body included, so don't quote a tag in a message that shouldn't run it. + +A push without a tag starts nothing and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. Which branches may run the Claude session is decided only by the `app-sim-claude` environment's branch rule in the repository settings: on a branch it doesn't allow, a run that asks for Claude fails at the environment check, and because the app job holds the environment, its scripted test fails with it. Ask for Claude only on branches the rule allows. + +Results: the job log, the job summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. + +## How long it takes, what it costs + +- `plan` (ubuntu, 15 s) names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. +- `core` (macOS, 35โ€“45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). +- `backend` (macOS, 20โ€“30 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. +- `app` (macOS): the Xcode build 8โ€“22 min, the smoke test about 10 min, the backend start about 10 min when asked, Claude as long as its plan needs. + +A run with reused builds and no extras takes about 26 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. Caches are evicted within hours (the repository is over its cache limit); the artifacts are what make runs fast. + +## The Claude session + +It runs after the smoke test, in the same job, on the simulator the smoke test leaves running, once per layout listed in `claude-scope`, with Claude Code installed from npm at a pinned version. The job then references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; jobs without Claude reference no environment. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it later holds the Claude token. + +- `claude-scope`: the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session. +- `device-test-plan.md`: the plan (`| ID | Do | Expect |` tables). On master it is a three-case smoke plan; a feature branch replaces it with its own and names its cases in `claude-scope`. +- `claude-prompt.md`: what any session needs to know about the app, the `ui` helper and the test backend; `claude-test.sh` adds the `ui` help, the session's cases, layout, start state and limits, the seeded backend and the plan. +- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files if the token appears in them. +- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`. + +Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; a 50-case feature plan took 300โ€“450 turns, 30โ€“60 minutes and $1.1โ€“1.7 per session, estimated at API prices. Every run spends the subscription's usage limits, which interactive sessions share. + +## Driving the app: `ui` + +`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. + +## The test backend + +`backend.sh start ` (with `[ci-backend]` or the `backend` input) starts, on the runner, against the preset SMP and XFTP servers: + +- a chat relay for channels, and a SimpleX Directory whose address it writes to `/directory-link`; +- a CLI peer `ci_admin` that creates, registers and gets approved groups and channels with the profile JSON the app sends: `ci_group` and `ci_channel` ("ci seed"), `ci_cards`, six entries with app-size avatars made by `avatar.swift` ("ci avatar"), a long name, markdown in a description, and `cinews`, `cinews_digest`, `cinews_weekly`; +- a local SMP server with a `[NAMES]` section and `names-resolver.py`, the names `cinews.testing`, `ciboth.testing`, `cialice.testing` and `cibobby.testing` claimed by their owners, and the contacts cialice and Robert, online and accepting connections; its address goes to `/names-server`; +- checks before the app starts: a search sent to the directory as a message must list `ci_group` and `ci_channel`, and `/_connect plan` must resolve each name from a fresh peer. + +Not yet verified on master: the backend ran on `f/directory-search`'s code, and its checks were rewritten for master's (the directory answers searches sent as messages there; service requests need the feature branch). + +It writes what it seeded to `/seed.md` (the Claude sessions get it) and logs to `/*.log`; `stop`, `directory-stop`, `directory-start` and `listings` are its other commands. The app reaches it through the simulator, which shares the runner's network; pointing the app at the names server is a few steps in Settings, described in `claude-prompt.md`. + +## Other files + +- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5. +- `earlier-run.sh `: finds the newest run of this branch in this repository with that artifact; artifacts from forks and other branches are never used. diff --git a/scripts/ci/ios-sim/avatar.swift b/scripts/ci/ios-sim/avatar.swift new file mode 100644 index 0000000000..e1b0925335 --- /dev/null +++ b/scripts/ci/ios-sim/avatar.swift @@ -0,0 +1,87 @@ +// Makes a group avatar the way the app makes profile images (resizeImageToStrSize in apps/ios/SimpleXChat/ImageUtils.swift): +// a JPEG at quality 0.85 drawn at scale 1, shrunk by sqrt(size / 12500), at most halved per step, +// until its data URI is at most 12500 characters. The picture is synthetic and photo-like: a gradient, +// discs and noise, with more noise for a higher detail. +// Usage: avatar [side in pixels, default 1024] + +import CoreGraphics +import Foundation +import ImageIO + +let maxDataSize = 12500 +let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + +struct Random { + var state: UInt64 + + mutating func next() -> Double { + state = state &* 6364136223846793005 &+ 1442695040888963407 + return Double(state >> 11) / Double(UInt64(1) << 53) + } + + mutating func color(alpha: Double = 1) -> CGColor { + let components: [CGFloat] = [next(), next(), next(), alpha] + return CGColor(colorSpace: colorSpace, components: components)! + } +} + +func context(_ width: Int, _ height: Int) -> CGContext { + CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! +} + +func picture(side: Int, detail: Double, random: inout Random) -> CGImage { + let ctx = context(side, side) + let gradient = CGGradient(colorsSpace: colorSpace, colors: [random.color(), random.color()] as CFArray, locations: [0, 1])! + ctx.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: 0, y: side), options: []) + for _ in 0..<24 { + let r = Double(side) * (0.05 + 0.25 * random.next()) + let alpha = 0.4 + 0.5 * random.next() + ctx.setFillColor(random.color(alpha: alpha)) + ctx.fillEllipse(in: CGRect(x: Double(side) * random.next() - r, y: Double(side) * random.next() - r, width: 2 * r, height: 2 * r)) + } + let pixels = ctx.data!.bindMemory(to: UInt8.self, capacity: ctx.bytesPerRow * side) + let amplitude = 96 * detail + for y in 0.. String { + let data = NSMutableData() + let destination = CGImageDestinationCreateWithData(data as CFMutableData, "public.jpeg" as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.85] as CFDictionary) + CGImageDestinationFinalize(destination) + return "data:image/jpg;base64," + (data as Data).base64EncodedString() +} + +func reduced(_ image: CGImage, ratio: Double) -> CGImage { + let width = Int(floor(Double(image.width) / ratio)) + let height = Int(floor(Double(image.height) / ratio)) + let ctx = context(width, height) + ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return ctx.makeImage()! +} + +let args = CommandLine.arguments +guard args.count == 4 || args.count == 5, let seed = UInt64(args[1]), let detail = Double(args[2]), let side = Int(args.count == 5 ? args[4] : "1024") else { + print("usage: avatar [side in pixels, default 1024]") + exit(1) +} +var generator = Random(state: seed) +var image = picture(side: side, detail: detail, random: &generator) +var uri = jpegDataURI(image) +var steps = 0 +while uri.count > maxDataSize { + image = reduced(image, ratio: min((Double(uri.count) / Double(maxDataSize)).squareRoot(), 2)) + uri = jpegDataURI(image) + steps += 1 +} +try uri.write(toFile: args[3], atomically: true, encoding: .utf8) +print("\(args[3]): \(image.width)x\(image.height) px after \(steps) steps, \(uri.count) characters") diff --git a/scripts/ci/ios-sim/backend.sh b/scripts/ci/ios-sim/backend.sh new file mode 100755 index 0000000000..02b293a9f9 --- /dev/null +++ b/scripts/ci/ios-sim/backend.sh @@ -0,0 +1,477 @@ +#!/bin/bash + +# Test backend on the app runner: a chat relay and a directory service on the preset servers, with groups and +# channels registered and approved by a CLI peer, and a search that must find them; and for SimpleX names, +# a local SMP server with a name resolver, names claimed by channels and by contacts that accept connections. +# Usage: +# scripts/ci/ios-sim/backend.sh start writes the directory's short address to /directory-link, +# the names server's address to /names-server +# and what was seeded to /seed.md +# scripts/ci/ios-sim/backend.sh stop stops everything it started, prints the logs +# scripts/ci/ios-sim/backend.sh directory-stop stops the directory only +# scripts/ci/ios-sim/backend.sh directory-start starts it again +# scripts/ci/ios-sim/backend.sh listings prints the directory's listed groups and channels +# Everything it starts keeps running between start and stop. + +set -euo pipefail + +directory_name="SimpleX Directory" +admin=ci_admin +search_text="ci seed" +avatar_search_text="ci avatar" +long_name=ci_long_name_entry_for_layout_truncation_checks +names_resolver_port=18080 + +# Runs one chat command in a CLI client that stays online for after it; the output is in $work/out. +chat() { + local db=$1 secs=$2 cmd=$3 + shift 3 + local status=0 + "$bin/simplex-chat" -d "$work/$db" -e "$cmd" -t "$secs" --execute-log all "$@" < /dev/null > "$work/out" 2>&1 || status=$? + cat "$work/out" >> "$work/$db-cli.log" + echo "::group::$db: $(cut -c1-200 <<< "$cmd")" + cat "$work/out" + echo "::endgroup::" + return "$status" +} + +admin() { + chat "$admin" "$1" "$2" --user-display-name "$admin" +} + +# A command that resolves a SimpleX name: given -s, the client uses that server alone, with every role, names included. +# -s also saves it among the user's servers, and later runs would put queues on it that the directory can't reach +# (it routes to unknown servers through a preset proxy), so it is then kept for names only. +named() { + local db=$1 user=$2 secs=$3 cmd=$4 + local status=0 + chat "$db" "$secs" "$cmd" --user-display-name "$user" -s "$names_server" || status=$? + db_query "$db" "UPDATE protocol_servers SET role_storage = 0, role_proxy = 0, role_names = 1 WHERE host = 'localhost'" + return "$status" +} + +expect_out() { + if ! grep -qE "$1" "$work/out"; then + echo "error: \"$1\" not in the output" + return 1 + fi +} + +expect_no_error() { + if grep -qiE "error" "$work/out"; then + echo "error: the command failed" + return 1 + fi +} + +# Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress. +admin_until() { + local pattern=$1 secs=$2 cmd=$3 + local deadline=$((SECONDS + secs)) + until admin 5 "$cmd" && grep -qE "$pattern" "$work/out"; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: \"$pattern\" not shown by \"$cmd\" after $secs s" + return 1 + fi + done +} + +db_query() { + sqlite3 "$work/$1_chat.db" "$2" +} + +listings_query="SELECT gp.display_name AS name, gp.short_descr AS description, g.summary_current_members_count AS members, length(gp.image) AS image_characters + FROM sx_directory_group_regs r + JOIN groups g ON g.group_id = r.group_id + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE r.group_reg_status = 'active' + ORDER BY g.summary_current_members_count DESC, r.group_id" + +start_relay() { + nohup "$bin/simplex-chat" -d "$work/relay" --relay --headless --user-display-name ci_relay < /dev/null > "$work/relay.log" 2>&1 & + echo $! > "$work/relay.pid" + local deadline=$((SECONDS + 120)) + until grep -qE '^https://[^ ]+/r#' "$work/relay.log"; do + if ! kill -0 "$(cat "$work/relay.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the relay did not create its address" + cat "$work/relay.log" + return 1 + fi + sleep 1 + done + relay_link=$(grep -m 1 -oE '^https://[^ ]+/r#[^ ]+' "$work/relay.log") + echo "relay address: $relay_link" +} + +# The address is created here, like the directory tests do, with auto-accept on before the service subscribes to it. +create_directory() { + chat directory 1 "/ad pq_ratchet=on" --create-bot-display-name "$directory_name" + directory_link=$(new_address) + chat directory 1 "/auto_accept on" + expect_out "^auto_accept on" + echo "$directory_link" > "$work/directory-link" + echo "directory address: $directory_link" +} + +start_directory() { + # -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin + nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 & + echo $! > "$work/directory.pid" + echo "$1" > "$work/super-user" + sleep 5 + if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then + echo "error: the directory service exited" + cat "$work/directory.log" + return 1 + fi +} + +stop_directory() { + local pid deadline + pid=$(cat "$work/directory.pid") + kill -INT "$pid" 2> /dev/null || true + deadline=$((SECONDS + 30)) + while kill -0 "$pid" 2> /dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + kill -KILL "$pid" 2> /dev/null || true + fi + sleep 1 + done +} + +# A super user is known by contact ID, which exists only after the admin connects, so the service starts +# with nobody and is restarted with the admin. +connect_admin() { + admin 10 "/c $directory_link" + expect_out "connection request sent!" + admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10" + local id + id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'") + stop_directory + start_directory "$id:$admin" + echo "directory restarted with super user $id:$admin" +} + +# The directory sends each registration to super users as a message with the exact /approve command, +# and tells the owner "The group ID N (name) is approved". +approve() { + local name=$1 + admin_until "/approve [0-9]+:$name [0-9]+" 300 "/tail @'$directory_name' 20" + local cmd + cmd=$(grep -oE "/approve [0-9]+:$name [0-9]+" "$work/out" | tail -n 1) + admin 5 "@'$directory_name' $cmd" + admin_until "\($name\) is approved" 120 "/tail @'$directory_name' 20" +} + +# The profile the app sends to create a group or a channel (AddGroupView, AddChannelView), with an optional image file. +group_profile() { + local name=$1 descr=$2 prefs=$3 image_file=${4:-} + if [ -n "$image_file" ]; then + jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" --rawfile i "$image_file" '{displayName: $n, fullName: "", shortDescr: $d, image: $i, groupPreferences: $p}' + else + jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" '{displayName: $n, fullName: "", shortDescr: $d, groupPreferences: $p}' + fi +} + +seed_group() { + local name=$1 descr=$2 image_file=${3:-} + admin 1 "/_group 1 $(group_profile "$name" "$descr" '{"history":{"enable":"on"}}' "$image_file")" + expect_out "group #$name .*is created" + admin 5 "/a $name '$directory_name' admin" + expect_out "invitation to join the group #$name sent to" + approve "$name" +} + +setup_relay() { + admin 1 "/relays name=ci_relay $relay_link" + expect_out "^ok$" + relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0") +} + +create_channel() { + local name=$1 descr=$2 image_file=${3:-} + admin 10 "/_public group 1 $relay_id $(group_profile "$name" "$descr" '{"history":{"enable":"on"},"support":{"enable":"off"}}' "$image_file")" + expect_out "group #$name .*is created" + local group_id + group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = '$name'") + admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id" +} + +list_channel() { + local name=$1 + admin 5 "/share chat #$name @'$directory_name'" + expect_out "link to join channel #$name" + approve "$name" +} + +seed_channel() { + create_channel "$@" + list_channel "$1" +} + +channel_link() { + admin 1 "/show link #$1" >&2 + grep -m 1 -oE 'https://[^ ]+/c#[^ ]+' "$work/out" +} + +# The short address that /ad printed. +new_address() { + local address + address=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out") + case "$address" in + https://*/a#*) echo "$address" ;; + *) + echo "error: no short address in the output" >&2 + return 1 + ;; + esac +} + +# Avatars at the size the app makes them (avatar.swift), with different amounts of detail; +# and small ones for ci_group, ci_channel and ci_cards, so that several fit in one size-limited search response. +make_avatars() { + xcrun swiftc -O -o "$work/avatar" "$(dirname "$0")/avatar.swift" + local i + for i in 1 2 3 4 5 6; do + "$work/avatar" "$i" "0.$((i + 2))" "$work/avatar-$i.txt" + done + for i in 7 8 9; do + "$work/avatar" "$i" 0 "$work/avatar-$i.txt" 64 + done +} + +seed_avatars() { + local i + for i in 1 2 3; do + seed_group "ci_avatar_g$i" "CI avatar group $i" "$work/avatar-$i.txt" + seed_channel "ci_avatar_c$i" "CI avatar channel $i" "$work/avatar-$((i + 3)).txt" + done +} + +# A local SMP server whose [NAMES] section points at names-resolver.py, which answers from names.json. +start_names() { + echo "{}" > "$work/names.json" + nohup python3 "$(dirname "$0")/names-resolver.py" "$names_resolver_port" "$work/names.json" < /dev/null > "$work/names-resolver.log" 2>&1 & + echo $! > "$work/names-resolver.pid" + export SMP_SERVER_CFG_PATH=$work/smp-server/etc SMP_SERVER_LOG_PATH=$work/smp-server/log + "$bin/smp-server" init -y --disable-store-log --no-password --fqdn localhost --disable-web > "$work/out" 2>&1 || { + cat "$work/out" + return 1 + } + cat "$work/out" + names_server=$(grep -m 1 -oE 'smp://[^ ]+@localhost' "$work/out") + sed -i '' \ + -e 's/^enable: off$/enable: on/' \ + -e "s|^# resolver_endpoint: http://127.0.0.1:8000\$|resolver_endpoint: http://127.0.0.1:$names_resolver_port|" \ + -e 's/^port = 5223,443$/port = 5223/' \ + "$SMP_SERVER_CFG_PATH/smp-server.ini" + grep -E '^(enable: on|resolver_endpoint|port = )' "$SMP_SERVER_CFG_PATH/smp-server.ini" + nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 & + echo $! > "$work/smp-server.pid" + local deadline=$((SECONDS + 60)) + until nc -z 127.0.0.1 5223 2> /dev/null; do + if ! kill -0 "$(cat "$work/smp-server.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the names server did not start" + cat "$work/smp-server.log" "$work/names-resolver.log" + return 1 + fi + sleep 1 + done + echo "$names_server" > "$work/names-server" + echo "names server: $names_server" +} + +register_name() { + local name=$1 contact=$2 channel=$3 + jq --arg n "$name" --arg ct "$contact" --arg ch "$channel" \ + '.[$n] = {simplexContact: ([$ct] | map(select(. != ""))), simplexChannel: ([$ch] | map(select(. != "")))}' \ + "$work/names.json" > "$work/names.json.new" + mv "$work/names.json.new" "$work/names.json" +} + +# A contact that claims a name and accepts every connection; it keeps running, so the app can connect to it. +start_contact() { + local db=$1 display_name=$2 name=$3 + chat "$db" 1 "/ad" --user-display-name "$display_name" + local address + address=$(new_address) + chat "$db" 1 "/auto_accept on" --user-display-name "$display_name" + expect_out "^auto_accept on" + register_name "$name" "$address" "" + named "$db" "$display_name" 5 "/_set domain 1 $name" + expect_no_error + # --headless is for relays only; a command with a long -t keeps the client online, accepting contacts, for 6 hours + nohup "$bin/simplex-chat" -d "$work/$db" -e "/auto_accept on" -t 21600 --user-display-name "$display_name" < /dev/null > "$work/$db.log" 2>&1 & + echo $! > "$work/$db.pid" +} + +# A name is claimed before its channel is listed: a profile change after approval hides it until approved again. +seed_names() { + local address link + create_channel cinews "CI news channel" + link=$(channel_link cinews) + register_name cinews.testing "" "$link" + named "$admin" "$admin" 5 "/public group access #cinews domain=cinews.testing" + expect_out "updated public group access: domain=cinews.testing" + list_channel cinews + seed_group cinews_digest "CI news digest" + seed_group cinews_weekly "CI news weekly" + + create_channel ciboth "CI both channel" + admin 1 "/ad" + address=$(new_address) + link=$(channel_link ciboth) + register_name ciboth.testing "$address" "$link" + named "$admin" "$admin" 5 "/_set domain 1 ciboth.testing" + expect_no_error + named "$admin" "$admin" 5 "/public group access #ciboth domain=ciboth.testing" + expect_out "updated public group access: domain=ciboth.testing" + + start_contact ci_alice cialice cialice.testing + start_contact ci_robert Robert cibobby.testing +} + +# What the app will resolve, from a client that knows none of the names' owners. +probe_names() { + local target pattern + for target in "#cinews.testing|ok to connect via relays" "@cialice.testing|contact address: ok to connect" "@cibobby.testing|contact address: ok to connect" "ciboth.testing|You can also connect to @ciboth"; do + pattern=${target#*|} + target=${target%%|*} + named probe ci_probe 5 "/_connect plan 1 $target" + expect_out "$pattern" + done + named probe ci_probe 5 "/_connect plan 1 #cinone.testing" || true + echo "names resolve: cinews, cialice, cibobby, ciboth; cinone is not registered" +} + +# The directory's reply to a search starts with "Found N group(s)"; earlier messages name the same groups. +in_search_reply() { + local reply + reply=$(sed -n '/Found [0-9]* group(s)/,$p' "$work/out") + grep -qF "$1" <<< "$reply" +} + +# A search sent as a message, which any client can send. +probe_search() { + admin 5 "@'$directory_name' $search_text" + local deadline=$((SECONDS + 120)) + until admin 5 "/tail @'$directory_name' 10" && in_search_reply "ci_group (CI seed group)" && in_search_reply "ci_channel (CI seed channel)"; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the directory's reply to \"$search_text\" does not list ci_group and ci_channel" + return 1 + fi + done + echo "search for \"$search_text\" found ci_group and ci_channel" +} + +write_seed() { + { + echo "# Test directory" + echo + echo "Directory address: $directory_link" + echo + echo "## Listed groups and channels" + echo + echo "| Search with | Entries | Seeded for |" + echo "|---|---|---|" + echo "| \`$search_text\` | ci_group (group), ci_channel (channel), each with a small avatar (64 px) | a group and a channel to find |" + echo "| \`ci cards\` | ci_cards (channel, with a small avatar) | a channel to join, so that the profile has a conversation |" + echo "| \`$avatar_search_text\` | ci_avatar_g1โ€ฆ3 (groups), ci_avatar_c1โ€ฆ3 (channels), each with an avatar the size the app makes | images at the size limit |" + echo "| \`ci long\` | $long_name (group): a long name and a two-line description | truncation |" + echo "| \`ci markdown\` | ci_markdown (group): markdown in the description | markdown shown literally |" + echo "| \`cinews\` | cinews (channel, with the name cinews.testing), cinews_digest and cinews_weekly (groups) | a search that matches a SimpleX name and listed entries |" + echo + sqlite3 -markdown "$work/directory_chat.db" "$listings_query" + echo + echo "## SimpleX names" + echo + echo "Names server, to add in the app for resolving names: \`$names_server\`" + echo + echo "| Name | Resolves to |" + echo "|---|---|" + echo "| cinews.testing | the channel cinews, listed in the directory (\"CI news channel\") |" + echo "| ciboth.testing | a contact address (ci_admin) and the channel ciboth, not listed |" + echo "| cialice.testing | the contact cialice, online and accepting connections |" + echo "| cibobby.testing | the contact Robert (display name without the name), online and accepting connections |" + echo "| any other, e.g. cinone.testing | not registered |" + echo + } > "$work/seed.md" +} + +dump_directory() { + echo "::group::directory registrations" + db_query directory "SELECT group_id, group_reg_status FROM sx_directory_group_regs" || true + echo "::endgroup::" +} + +start() { + bin=$work/bin + mkdir -p "$bin" + unzip -o -q "$1" -d "$bin" + "$bin/simplex-chat" --version + "$bin/simplex-directory-service" --version + "$bin/smp-server" --version + make_avatars + + start_names + start_relay + create_directory + start_directory "0:nobody" + trap dump_directory EXIT + connect_admin + seed_group ci_group "CI seed group" "$work/avatar-7.txt" + setup_relay + seed_channel ci_channel "CI seed channel" "$work/avatar-8.txt" + seed_channel ci_cards "CI cards channel" "$work/avatar-9.txt" + seed_avatars + seed_group "$long_name" "CI long entry: a description long enough to take two lines in the directory results, to check how the row truncates" + seed_group ci_markdown "CI markdown *bold* _italic_ [x](https://example.com)" + seed_names + probe_search + probe_names + write_seed +} + +stop() { + local p processes="directory relay ci_alice ci_robert smp-server names-resolver" + for p in $processes; do + if [ -f "$work/$p.pid" ]; then + kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true + fi + done + sleep 3 + for p in $processes; do + echo "::group::$p log" + cat "$work/$p.log" 2> /dev/null || true + echo "::endgroup::" + done +} + +case "${1:-}" in + start) + work=$3 + mkdir -p "$work" + start "$2" + ;; + stop) + work=$2 + stop + ;; + directory-stop) + work=$2 + stop_directory + ;; + directory-start) + work=$2 + bin=$work/bin + start_directory "$(cat "$work/super-user")" + ;; + listings) + work=$2 + sqlite3 -markdown "$work/directory_chat.db" "$listings_query" + ;; + *) + echo "usage: $0 start | stop | directory-stop | directory-start | listings " + exit 1 + ;; +esac diff --git a/scripts/ci/ios-sim/build-backend.sh b/scripts/ci/ios-sim/build-backend.sh new file mode 100755 index 0000000000..44b6c4c0c0 --- /dev/null +++ b/scripts/ci/ios-sim/build-backend.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Builds the CLI, the directory service and the SMP server (for SimpleX names) that the app job runs as its test backend. +# Usage: scripts/ci/ios-sim/build-backend.sh +# They need the server-capable configuration (no client_library), as build.yml's macOS job builds the CLI. + +set -euo pipefail + +mkdir -p "$(dirname "$1")" +out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +openssl_dir=$(brew --prefix openssl@3) + +ghc --version +cabal --version +du -sh ~/.cabal/store dist-newstyle || true + +cat > cabal.project.local < +# The zip has the same files as the Hydra package that scripts/ios/prepare.sh expects. + +set -euo pipefail + +mkdir -p "$(dirname "$1")" +out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +ghc_ver=$(ghc --numeric-version) +version=$(sed -n 's/^version: *//p' simplex-chat.cabal) +openssl_dir=$(brew --prefix openssl@3) +ffi_dir=$(brew --prefix libffi) +gmp_dir=$(brew --prefix gmp) + +ghc --version +cabal --version +echo "rts extra-libraries: $(ghc-pkg field rts extra-libraries --simple-output)" +echo "ghc-bignum extra-libraries: $(ghc-pkg field ghc-bignum extra-libraries --simple-output)" +find "$(ghc --print-libdir)" -name 'lib*ffi*.a' -o -name 'libgmp*.a' -o -name 'libHSrts*_thr.a' +du -sh ~/.cabal/store dist-newstyle || true + +cat > cabal.project.local </dev/null || true; } \ + | awk '$NF ~ /^_(ffi_call|__gmpz_init|hs_init_with_rtsopts|chat_migrate_init_key|EVP_CIPHER_CTX_new|sqlite3_key)$/ {print " " $NF}' \ + | sort -u + +pkg=$(mktemp -d) +cp "$build_dir"/libHSsimplex-chat-*.a "$ffi_dir"/lib/libffi.a "$gmp_dir"/lib/libgmp.a "$gmp_dir"/lib/libgmpxx.a "$pkg" +chmod u+w "$pkg"/*.a +for f in "$pkg"/libHSsimplex-chat-*.a; do + strip -x "$f" +done +ls -la "$pkg" + +rm -f "$out" +(cd "$pkg" && zip -9 "$out" *.a) +ls -la "$out" diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py new file mode 100755 index 0000000000..ab131ae997 --- /dev/null +++ b/scripts/ci/ios-sim/claude-guard.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain +# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat, echo and env without +# asking in every permission mode, and Bash inherits CLAUDE_CODE_OAUTH_TOKEN, so permission rules alone are not enough. +# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG +# when it is set, as evidence that the hook ran. + +import json +import os +import re +import sys + +# nothing that chains, pipes, redirects, substitutes, expands or escapes +UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") + +call = json.load(sys.stdin) +command = (call.get("tool_input") or {}).get("command", "").strip() +allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command) +if os.environ.get("CLAUDE_GUARD_LOG"): + with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log: + log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n") +if not allowed: + print(f"Only the ui helper runs here, as one command: ui [args]. Refused: {command[:200]}", file=sys.stderr) + sys.exit(2) diff --git a/scripts/ci/ios-sim/claude-prompt.md b/scripts/ci/ios-sim/claude-prompt.md new file mode 100644 index 0000000000..84c3903f82 --- /dev/null +++ b/scripts/ci/ios-sim/claude-prompt.md @@ -0,0 +1,59 @@ +# Device test session: SimpleX Chat iOS + +You are testing the SimpleX Chat iOS app in an iPhone 17 simulator (iOS 26.5, screen 402 x 874 points) on a GitHub Actions runner. You run the cases of the device test plan (at the end of this prompt) that the section "This session" names, in the layout it names, and write a report. You cannot change the app or the runner; anything wrong you find is a finding for the report. + +## Tools + +- **Bash runs only the `ui` helper**, one command per call: `ui tap "Create profile"`, `ui find Settings`. Any other program, a pipe, a redirection, `;` or `&&` is denied. Its commands are listed under "The ui commands" below. +- **Read, Write and Edit work only in the current directory.** Screenshots go to `shots/` (look at the `-view.jpg` files, they are smaller), and you write `report.md` here. This whole prompt, with the plan, is also `prompt.md` here: read it again when you are unsure, and after the conversation has been compacted. + +## What you need to know about the app and the helper + +- **Layouts.** With the one-hand UI (reachable toolbar, the default) the chat list is drawn from the bottom up: the toolbar (your profile image and the "Compose" pencil) is at the bottom, the search field just above it (y about 754), the list tags ("Add list" when there are no lists) above the field, and chats stack upwards from there. With the top bar the toolbar and the search field are at the top. A profile without conversations sees onboarding pages ("Talk to someone") in place of the list, without the search field. Stay in the layout the session names; the app starts in it, and `ui relaunch` keeps it. +- **Positions.** "Above" means a smaller y. Judge order and position from the tree's coordinates, and look at a screenshot when you judge layout (truncation, overlap, images). +- **The search field** has no label; tap it with `ui tap-value "Search or paste SimpleX link"` when empty, or by its text. To clear it, tap the field so it is focused, then "Cancel" next to it ("Cancel" exists only while the field is focused; it also hides the keyboard); tapping the "Close" image inside the field did not clear it in an earlier run. Type text as one quoted argument (`ui type "ci seed"`). The keyboard capitalizes the first letter and autocorrects words (an earlier run saw "ci" become "Ci" and a typed name become another word when a button was tapped), so check the field's value in the tree after typing, and before typing check that the field shows its placeholder. The keyboard's Search key is `ui key return`; the keyboard's keys are below the screen and not listed in the tree, and the screenshots do not show the keyboard. +- **Pasting**: `ui clipboard ""`, tap the empty field once to focus it and once more to open its menu, then tap its "Paste" item (two elements carry that label: use `ui tap-xy` on the one `ui find Paste` shows in the menu). +- **Disabled elements** end with "disabled" in the tree. +- **Ambiguous labels.** When `ui tap` says several elements match a label (seen for "Paste", "Channels", "Account"), tap the one you mean with `ui tap-xy` at its centre, taken from `ui find`. +- **Hidden elements.** The tree always contains the closed user picker: the profile name, "Create SimpleX address", "Chat preferences", "Your chat profiles", "Use from desktop", "Settings", at coordinates on the screen even while it is hidden. Do not tap them unless you opened the picker by tapping your profile image in the toolbar (`Image "Account"`, 32 x 32; tap its centre with `ui tap-xy`). +- **Settings โ†’ Reset all hints:** open the picker, "Settings", scroll to the bottom, the app version row ("vโ€ฆ"), "Developer", "Reset all hints". Go back with the navigation back buttons and close Settings by swiping it down. +- **New chat** is the "Compose" button (the pencil) in the toolbar. With the top bar the toolbar is hidden while the search field is focused. +- **Joining** (the app's normal flow, from `NewChatView.swift`; an earlier session took its first step for a failure). Tapping a group or channel the profile does not know yet, from a link or a list, shows an alert with its name (and subscriber count for a channel) and "Cancel" / "Open channel" or "Open group": that is the start of joining, not a sign that the entry is known. "Open โ€ฆ" opens the chat, not yet joined, with "Tap Join channel" in it and a "Join channel" / "Join group" bar at the bottom; tapping that bar joins (a channel then shows "you are subscriber"; a group may stay "connecting"). A contact works the same way: "Open new chat", then the chat's connect button. An entry already opened, joined or connecting shows the same kind of alert, plus your role ("You are a subscriber", "You are a member") once joined. So each case that joins uses the entry named for it under "This session", which no earlier case has touched. Opened and joined chats appear in the chat list, and the profile is no longer "without conversations". +- **A second profile:** open the picker, "Your chat profiles", add a profile; a new profile has no conversations. Switch back to the first profile when done. +- **Text size:** `ui text-size accessibility-large`, and back to `ui text-size medium` afterwards. Only one phone size is available. +- **If the app gets stuck**, `ui relaunch` restarts it (search text is cleared, everything else stays). +- Waiting: prefer `ui wait ` over repeated trees; `ui wait` returns at once when the text is already shown. + +## The test backend + +When "This session" gives the test directory's address, the runner also runs a test backend: a chat relay, a SimpleX Directory with the groups and channels listed under "Test directory" below, a server that resolves SimpleX names, and contacts that accept connections. `ui directory stop|start` stops and starts the directory (after `ui directory start`, wait about 15 s before using it), and `ui listings` prints what it lists. + +**SimpleX names**: the names server's address is under "This session"; the names it knows are under "Test directory" โ†’ "SimpleX names" (all end in `.testing`; a bare word of 5 or more letters is completed with `.testing`, so `cinews` is `cinews.testing`). Set the app up for it once, before the first case that needs names (if an earlier session in this run did it, check it instead: the server is listed under "Your servers"): + +1. Open the picker (your profile image), "Settings", "Network & servers". +2. Under "Preset servers", open each operator ("SimpleX Chat", "Flux"), turn "To resolve names" off, and go back. Otherwise the app may ask the real servers, which do not know the test names. +3. "Your servers" โ†’ "Add server" โ†’ "Enter server manually". Put the names server's address in the address field by pasting it (typing it would let the keyboard capitalize it), check it in the tree, and go back. +4. In "Your servers", open the new server and set "To receive" to "no", "For private routing" to "no" and "To resolve names" to "yes" (each is a picker), then go back. +5. On "Network & servers", tap "Save servers" (and "Save" if asked). Close Settings. + +## How to run the cases + +- Run each case of the session's list, in the plan's order unless a case needs another first. +- The plan's tables (ID, Do, Expect) are at the end of `prompt.md` in the current directory: read the row before running a case and do exactly what it says. An earlier session did not find them and filed other actions under some IDs. +- For each case, do what the plan says, decide pass or fail against the plan's "Expect" column, and take at least one `ui shot` at the moment that shows the result, named after the case and layout without a number, for example `ui shot S1-reachable` (the helper numbers the files). +- Pass only what you saw: quote the evidence (element labels, values and coordinates from the tree, or what a screenshot shows). If an expectation cannot be checked here, say which part and why, and mark the case passed for the parts you checked only if those parts are the substance of the case; otherwise skipped. +- **Skip** any case that needs something this runner lacks, saying exactly what is missing. Do not skip a case only because it takes several steps. +- Other app bugs you notice are findings: write what you did, what you saw, what you expected, and the screenshot. +- Mind the session's turn limit under "This session": every `ui` command and every file edit is a turn. + +## The report + +Write `report.md` in the current directory as you go, so a session that is cut short still leaves its results. **Before the first case, create it with the Results table already holding one row for every case of the session's list, Result `todo`**, and fill a row in as soon as its case is done. Before each case, and always after the conversation has been compacted, read `report.md` to see which cases are left (and `prompt.md` for anything else you need). The session is finished only when no row says `todo`. Its sections: + +1. `# Device test report: ` and one line on the app state at the start. +2. `## Results`: a table with one row per case of the session's list: `| ID | Result | Evidence | Screenshots |`, Result being pass, fail or skipped (with the reason). +3. Any section the plan asks for. +4. `## Findings`: app bugs, each with steps, what you saw, what you expected, screenshots. +5. `## Harness notes`: anything about `ui` or the runner that got in the way. + +End the session by making sure `report.md` is complete and no row says `todo`; your final message is a three-line summary: passed, failed and skipped counts, and the most important finding. diff --git a/scripts/ci/ios-sim/claude-scope b/scripts/ci/ios-sim/claude-scope new file mode 100644 index 0000000000..a846ae4ac5 --- /dev/null +++ b/scripts/ci/ios-sim/claude-scope @@ -0,0 +1,8 @@ +# What claude-test.sh runs: one Claude session per layout (reachable, top), each with its cases, and each session's bounds. +# A case runs in both layouts only when the layouts render it differently; the rest run in one. +# entries- (optional) tells that session which directory entries and names its cases use, so none meets an entry already known. +model: claude-haiku-5-5 +layouts: reachable +cases-reachable: S1-S3 +max-turns: 60 +max-budget-usd: 3 diff --git a/scripts/ci/ios-sim/claude-test.sh b/scripts/ci/ios-sim/claude-test.sh new file mode 100755 index 0000000000..5d72d03a9b --- /dev/null +++ b/scripts/ci/ios-sim/claude-test.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +# Runs the device test plan with Claude Code on this runner after the smoke test, one session per layout, +# as claude-scope says. A session drives the app only through the ui helper, and reads and writes only its +# own directory /: prompt.md, report.md, shots/, transcript.jsonl (stream-json). +# Usage: scripts/ci/ios-sim/claude-test.sh +# Needs CLAUDE_CODE_OAUTH_TOKEN, SIM_UDID (the booted simulator with the app) and, when the test backend runs, BACKEND_DIR. + +set -euo pipefail + +token=${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set} +# only the claude processes get the token, nothing else this script starts +unset CLAUDE_CODE_OAUTH_TOKEN + +mkdir -p "$1" +out=$(cd "$1" && pwd) +dir=$(cd "$(dirname "$0")" && pwd) +: "${SIM_UDID:?SIM_UDID must be the simulator UDID}" +BACKEND_DIR=${BACKEND_DIR:-} + +scope() { + sed -n "s/^$1: *//p" "$dir/claude-scope" +} + +model=$(scope model) +layouts=$(scope layouts) +max_turns=$(scope max-turns) +max_budget=$(scope max-budget-usd) + +export PATH="$dir:$PATH" BACKEND_DIR APP_LOG=$out/app.log CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +if [ -n "$BACKEND_DIR" ]; then + cp "$BACKEND_DIR/seed.md" "$out/seed.md" +fi +settings=$out/settings.json +cat > "$settings" << EOF +{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$dir/claude-guard.py"}]}]}} +EOF + +xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$APP_LOG" 2>&1 & +log_pid=$! +trap 'kill "$log_pid" 2> /dev/null || true' EXIT + +layout_name() { + case "$1" in + reachable) echo "the one-hand UI (reachable toolbar)" ;; + top) echo "the top bar" ;; + esac +} + +write_prompt() { + local layout=$1 previous=$2 + cat "$dir/claude-prompt.md" + echo + echo "## The ui commands" + echo + echo '```' + ui || true + echo '```' + echo + echo "## This session" + echo + echo "- Layout: $(layout_name "$layout")." + echo "- Cases: $(scope "cases-$layout")." + local entries + entries=$(scope "entries-$layout") + if [ -n "$entries" ]; then + echo "- Entries to use: $entries." + fi + if [ -n "$BACKEND_DIR" ]; then + echo "- The test directory's address: $(cat "$BACKEND_DIR/directory-link")." + echo "- Names server to add in the app: $(cat "$BACKEND_DIR/names-server")." + else + echo "- No test backend runs in this session." + fi + if [ -z "$previous" ]; then + echo "- At the start: the smoke test onboarded a profile (typed as \"citester\"; the keyboard may have autocorrected the name) and checked the chat list in both layouts; the profile has no conversations." + else + echo "- At the start: an earlier session ran the cases in $(layout_name "$previous") on this app, so the profile may have chats, other profiles may exist and hints may be changed; look at the state first." + fi + echo "- Limits: $max_turns turns and \$$max_budget (estimated at API prices). Keep report.md current as you go." + echo + if [ -n "$BACKEND_DIR" ]; then + cat "$out/seed.md" + echo + fi + cat "$dir/device-test-plan.md" +} + +status=0 +previous="" +for layout in $layouts; do + session=$out/$layout + mkdir -p "$session/shots" + ui layout "$layout" + write_prompt "$layout" "$previous" > "$session/prompt.md" + echo "::group::Claude session: $layout, $model, $max_turns turns, \$$max_budget" + session_status=0 + # outside the session's directory, so the session cannot change it + export CLAUDE_GUARD_LOG=$out/$layout-guard.log + : > "$CLAUDE_GUARD_LOG" + ( + cd "$session" + UI_OUT=$session/shots CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ + --model "$model" \ + --effort high \ + --restricted \ + --tools Bash,Read,Write,Edit \ + --allowedTools "Bash(ui)" "Bash(ui *)" "Read(/$session/**)" "Edit(/$session/**)" \ + --permission-mode dontAsk \ + --permission-prompts none \ + --strict-mcp-config \ + --max-turns "$max_turns" \ + --max-budget-usd "$max_budget" \ + --autocompact 200k \ + --no-session-persistence \ + --settings "$settings" \ + --output-format stream-json \ + --verbose \ + < prompt.md > transcript.jsonl 2> stderr.log + ) || session_status=$? + echo "claude exited with $session_status" + # the token must never be printed or uploaded, whatever the session did; grep reads it from a pipe, not from its arguments + if grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG"; then + rm -rf "$session" "$CLAUDE_GUARD_LOG" + echo "::error::the $layout session's files contained the token, so they were deleted before the upload" + session_status=1 + else + tail -n 20 "$session/stderr.log" + if [ -f "$session/report.md" ]; then + cat "$session/report.md" + else + echo "no report.md" + fi + echo "guard hook: $(grep -c '^allow' "$CLAUDE_GUARD_LOG" || true) allowed, $(grep -c '^refuse' "$CLAUDE_GUARD_LOG" || true) refused" + grep '^refuse' "$CLAUDE_GUARD_LOG" || true + fi + echo "::endgroup::" + if [ "$session_status" -ne 0 ]; then + status=$session_status + fi + previous=$layout +done +exit "$status" diff --git a/scripts/ci/ios-sim/claude-usage.sh b/scripts/ci/ios-sim/claude-usage.sh new file mode 100755 index 0000000000..79132707cb --- /dev/null +++ b/scripts/ci/ios-sim/claude-usage.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Reports what each Claude session of claude-test.sh spent, from the final result event of its transcript, +# to the job summary and to /claude-usage.json. total_cost_usd is Claude Code's own estimate +# at API prices, not a bill. +# Usage: scripts/ci/ios-sim/claude-usage.sh + +set -euo pipefail + +out=$1 +summary=${GITHUB_STEP_SUMMARY:-/dev/stdout} +sessions=0 +mkdir -p "$out" + +{ + echo "## Claude sessions" + echo + echo "| Session | Result | Turns | Duration | Input | Output | Cache read | Cache write | Per model, estimated at API prices | Total, estimated at API prices | Permission denials |" + echo "|---|---|---|---|---|---|---|---|---|---|---|" +} >> "$summary" + +for transcript in "$out"/*/transcript.jsonl; do + [ -f "$transcript" ] || continue + session=$(basename "$(dirname "$transcript")") + sessions=$((sessions + 1)) + result=$(jq -c -R 'fromjson? | select(.type == "result")' "$transcript" | tail -n 1) + if [ -z "$result" ]; then + echo "| $session | no result event: the session timed out or crashed | | | | | | | | | |" >> "$summary" + jq -n --arg s "$session" '{session: $s, result: "missing"}' > "$out/$session/usage.json" + continue + fi + jq --arg s "$session" '{session: $s, subtype, is_error, num_turns, duration_ms, usage: (.usage | {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}), modelUsage, total_cost_usd, permission_denials: (.permission_denials // [] | length)}' <<< "$result" > "$out/$session/usage.json" + jq -r --arg s "$session" '"| \($s) | \(.subtype)\(if .is_error then " (error)" else "" end) | \(.num_turns) | \(.duration_ms / 60000 | floor) min | \(.usage.input_tokens) | \(.usage.output_tokens) | \(.usage.cache_read_input_tokens) | \(.usage.cache_creation_input_tokens) | \([.modelUsage // {} | to_entries[] | "\(.key) $\(.value.costUSD // 0 | . * 100 | round / 100)"] | join(", ")) | $\(.total_cost_usd // 0 | . * 100 | round / 100) | \(.permission_denials // [] | length) |"' <<< "$result" >> "$summary" +done + +if [ "$sessions" -eq 0 ]; then + echo "No Claude session ran." >> "$summary" + echo "[]" > "$out/claude-usage.json" +else + jq -s '.' "$out"/*/usage.json > "$out/claude-usage.json" +fi +cat "$out/claude-usage.json" diff --git a/scripts/ci/ios-sim/device-test-plan.md b/scripts/ci/ios-sim/device-test-plan.md new file mode 100644 index 0000000000..cf82b7daf0 --- /dev/null +++ b/scripts/ci/ios-sim/device-test-plan.md @@ -0,0 +1,9 @@ +# Device test plan: smoke test + +The app as the scripted smoke test leaves it: a profile created through onboarding, without conversations. A branch that tests a feature replaces this plan with its own (same table format) and names its cases in `claude-scope`. + +| ID | Do | Expect | +|----|----|--------| +| S1 | Look at the screen: `ui tree`, then `ui shot S1-` | The chat list of a profile without conversations: the heading "Talk to someone", the cards "Let someone connect to you" and "Connect via link or QR code", and the toolbar with your profile image (`Image "Account"`, 32 x 32) and "Compose": at the bottom of the screen with the one-hand UI, at the top with the top bar | +| S2 | Tap "Let someone connect to you", then "Back" | The page "Create your link" with the cards "Invite someone privately" and "Create your public address" and a "Back" button; after "Back", "Talk to someone" again | +| S3 | Open the profile picker by tapping the toolbar's profile image, tap "Settings", then close Settings by swiping it down | The picker shows your profile name and "Settings"; Settings opens with "Your settings" and "Network & servers"; after closing, "Talk to someone" again | diff --git a/scripts/ci/ios-sim/earlier-run.sh b/scripts/ci/ios-sim/earlier-run.sh new file mode 100755 index 0000000000..752bea6292 --- /dev/null +++ b/scripts/ci/ios-sim/earlier-run.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Finds the newest run of this branch in this repository that uploaded an unexpired artifact with this name, +# and writes its id as run-id to GITHUB_OUTPUT. Needs GH_TOKEN with actions: read. +# Usage: scripts/ci/ios-sim/earlier-run.sh +# Artifacts from forks and other branches are not used: their workflows could upload anything under this name. + +set -euo pipefail + +name=$1 +run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq " + [.artifacts[] + | select((.expired | not) and .workflow_run.head_branch == \"$GITHUB_REF_NAME\" and .workflow_run.head_repository_id == .workflow_run.repository_id)] + | sort_by(.created_at) | last | .workflow_run.id // empty") +if [ -n "$run_id" ]; then + echo "$name: from run $run_id" + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" +else + echo "$name: not built yet" +fi diff --git a/scripts/ci/ios-sim/install-axe.sh b/scripts/ci/ios-sim/install-axe.sh new file mode 100755 index 0000000000..4b09cde437 --- /dev/null +++ b/scripts/ci/ios-sim/install-axe.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Installs a pinned AXe release the way its Homebrew formula (cameroncooke/homebrew-axe) does, rather than the tap's +# latest version, because it runs in the job that later holds the Claude token. Puts axe on the job's PATH. + +set -euo pipefail + +version=1.8.0 +sha256=87eb09cd49dfc74fa8f7f5a46a03dfe2f14518b01d819f6a4094eab0d344ea7f +dir=$RUNNER_TEMP/axe +mkdir -p "$dir/release" +curl --proto '=https' --tlsv1.2 -sSfL -o "$dir/axe.tar.gz" "https://github.com/cameroncooke/AXe/releases/download/v$version/AXe-macOS-homebrew-v$version.tar.gz" +echo "$sha256 $dir/axe.tar.gz" | shasum -a 256 -c +tar -xzf "$dir/axe.tar.gz" -C "$dir/release" +axe=$(find "$dir/release" -type f -name axe -print -quit) +for framework in "$(dirname "$axe")"/Frameworks/*.framework; do + codesign --force --sign - --timestamp=none "$framework" +done +codesign --force --sign - --timestamp=none "$axe" +"$axe" --version +dirname "$axe" >> "$GITHUB_PATH" diff --git a/scripts/ci/ios-sim/names-resolver.py b/scripts/ci/ios-sim/names-resolver.py new file mode 100755 index 0000000000..0e56318431 --- /dev/null +++ b/scripts/ci/ios-sim/names-resolver.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +# The name resolver that the test smp-server's [NAMES] section points at, as simplexmq's Server/Names/HttpResolver.hs +# calls it: GET /v2/resolve/ answers a NameResponse for the query "[]<.tld>" +# (or the plain name, which older clients send), GET /health answers {}. Registered names come from a JSON file, read on every request, so names can be added while it runs: +# {"cinews.testing": {"simplexChannel": ["https://..."], "simplexContact": []}, ...} +# Every other name is answered as available. +# Usage: names-resolver.py + +import json +import sys +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROUND_CONSTANTS = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +] +ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]] +MASK = (1 << 64) - 1 + + +def rol(v, n): + return ((v << n) | (v >> (64 - n))) & MASK if n else v + + +def keccak_f(a): + for rc in ROUND_CONSTANTS: + c = [a[x][0] ^ a[x][1] ^ a[x][2] ^ a[x][3] ^ a[x][4] for x in range(5)] + d = [c[(x - 1) % 5] ^ rol(c[(x + 1) % 5], 1) for x in range(5)] + a = [[a[x][y] ^ d[x] for y in range(5)] for x in range(5)] + b = [[0] * 5 for _ in range(5)] + for x in range(5): + for y in range(5): + b[y][(2 * x + 3 * y) % 5] = rol(a[x][y], ROTATIONS[x][y]) + a = [[b[x][y] ^ (~b[(x + 1) % 5][y] & b[(x + 2) % 5][y]) for y in range(5)] for x in range(5)] + a[0][0] ^= rc + return a + + +# Keccak-256 as Ethereum uses it (padding 0x01, not SHA3's 0x06), which hashlib does not have +def keccak256(data): + rate = 136 + padded = bytearray(data) + b"\x01" + bytes(-(len(data) + 1) % rate) + padded[-1] |= 0x80 + a = [[0] * 5 for _ in range(5)] + for offset in range(0, len(padded), rate): + for i in range(rate // 8): + a[i % 5][i // 5] ^= int.from_bytes(padded[offset + 8 * i:offset + 8 * i + 8], "little") + a = keccak_f(a) + return b"".join(a[i % 5][i // 5].to_bytes(8, "little") for i in range(4)) + + +def name_query(name): + label, tld = name.lower().split(".", 1) + return "[" + keccak256(label.encode()).hex() + "]." + tld + + +def name_response(names_file, query): + with open(names_file) as f: + names = json.load(f) + for name, links in names.items(): + # hashed from SMP v22 (NQHash), the plain name before (NQDomain) + if query.lower() in (name_query(name), name.lower()): + record = { + "name": name, + "nickname": "", + "website": "", + "location": "", + "simplexContact": links.get("simplexContact", []), + "simplexChannel": links.get("simplexChannel", []), + "eth": None, + "btc": None, + "xmr": None, + "dot": None, + "owner": "", + "resolver": "", + } + return name, {"registration": {"type": "registered", "nameRecord": record}} + return None, {"registration": {"type": "available", "pricing": {"registrationPrices": {}, "basePrice": 1000, "minLabelLength": 1}}} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + path = urllib.parse.unquote(self.path) + if path == "/health": + self.reply(200, {}) + elif path.startswith("/v2/resolve/"): + query = path[len("/v2/resolve/"):] + name, response = name_response(self.server.names_file, query) + print(f"resolve {query}: {name or 'not registered'}", flush=True) + self.reply(200, response) + else: + self.reply(404, {}) + + def reply(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format, *args): + pass + + +if __name__ == "__main__": + server = ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler) + server.names_file = sys.argv[2] + print(f"names resolver on 127.0.0.1:{sys.argv[1]}, names from {sys.argv[2]}", flush=True) + server.serve_forever() diff --git a/scripts/ci/ios-sim/prepare-libs.sh b/scripts/ci/ios-sim/prepare-libs.sh new file mode 100755 index 0000000000..78287b5ccf --- /dev/null +++ b/scripts/ci/ios-sim/prepare-libs.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Builds mac2ios, runs scripts/ios/prepare.sh on ~/Downloads/pkg-ios-aarch64-swift-json.zip, +# and fails unless every object in apps/ios/Libraries/sim is marked for the iOS simulator. + +set -euo pipefail + +mac2ios_rev=4dcb77d5ea896d749381806dfab5358851b08951 +tools=$(mktemp -d) +for f in mac2ios.c ar.h macho.h; do + curl --proto '=https' --tlsv1.2 -sSfL -o "$tools/$f" "https://raw.githubusercontent.com/zw3rk/mobile-core-tools/$mac2ios_rev/$f" +done +cc -O2 -o "$tools/mac2ios" "$tools/mac2ios.c" + +PATH="$tools:$PATH" scripts/ios/prepare.sh + +for f in apps/ios/Libraries/sim/*.a; do + echo "$f: $(du -h "$f" | cut -f1), $(lipo -info "$f")" + platforms=$({ otool -l "$f" || true; } | awk '$1 == "platform" {print $2}' | sort | uniq -c) + echo "${platforms:- no LC_BUILD_VERSION found}" + if echo "$platforms" | awk 'NF {print $2}' | grep -vE '^(7|IOSSIMULATOR)$' > /dev/null; then + echo "error: $f has objects for a platform other than the iOS simulator" + exit 1 + fi +done diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh new file mode 100755 index 0000000000..e9e245a1ab --- /dev/null +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Installs the app in a new iPhone simulator and runs the scripted UI cases with the ui helper: onboarding to the +# chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI. +# Usage: scripts/ci/ios-sim/smoke-test.sh +# Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log. +# Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running. + +set -euo pipefail + +app=$1 +export UI_OUT=$2 +bundle_id=chat.simplex.app +ui=$(dirname "$0")/ui +mkdir -p "$UI_OUT" + +axe --version +runtime=com.apple.CoreSimulator.SimRuntime.iOS-$(xcrun --sdk iphonesimulator --show-sdk-version | tr . -) +SIM_UDID=$(xcrun simctl create ci-iphone com.apple.CoreSimulator.SimDeviceType.iPhone-17 "$runtime") +export SIM_UDID +echo "simulator $SIM_UDID, runtime $runtime" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "SIM_UDID=$SIM_UDID" >> "$GITHUB_ENV" + echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV" +fi +xcrun simctl boot "$SIM_UDID" +xcrun simctl bootstatus "$SIM_UDID" -b +open -a Simulator --args -CurrentDeviceUDID "$SIM_UDID" + +xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$UI_OUT/app.log" 2>&1 & +log_pid=$! + +finish() { + local status=$? + "$ui" shot final || true + kill "$log_pid" || true + echo "::group::core start in the app log" + grep -E 'chatMigrateInit|startChat' "$UI_OUT/app.log" || true + echo "::endgroup::" + echo "::group::app log, last 300 lines" + tail -n 300 "$UI_OUT/app.log" + echo "::endgroup::" + find ~/Library/Logs/DiagnosticReports -name 'SimpleX*' -newer "$app" 2>/dev/null | while read -r report; do + echo "::group::crash report $report" + head -n 200 "$report" + echo "::endgroup::" + done || true + exit "$status" +} +trap finish EXIT + +expect_in_tree() { + local tree + tree=$("$ui" tree) + if ! grep -q "$@" <<< "$tree"; then + echo "error: ${*: -1} is not in the accessibility tree" + exit 1 + fi +} + +# iOS asks once whether the app may send notifications. The tree sometimes shows the app under this alert rather than +# the alert (run 38895987656), so its Allow button is also looked for at its place. +allow_notifications() { + local deadline=$((SECONDS + 30)) element + while [ "$SECONDS" -lt "$deadline" ]; do + if "$ui" tap Allow 1; then + return + fi + element=$("$ui" point 275 518 || true) + if grep -qF '"Allow"' <<< "$element"; then + "$ui" tap-xy 275 518 + return + fi + sleep 2 + done + echo "no notifications alert to allow" +} + +# a profile without conversations sees the onboarding pages in place of the list +expect_chat_list() { + expect_in_tree -F '"Talk to someone"' + expect_in_tree -F 'Button "Let someone connect to you"' + expect_in_tree -F 'Button "Connect via link or QR code"' + expect_in_tree -F 'Button "Compose"' + echo "in the $1 layout the chat list shows the onboarding pages and the toolbar" +} + +xcrun simctl install "$SIM_UDID" "$app" +xcrun simctl launch "$SIM_UDID" "$bundle_id" + +# onboarding to the chat list +"$ui" wait "Get started" 180 +"$ui" shot first-screen +"$ui" tap "Get started" + +"$ui" wait "Create profile" 30 +"$ui" tap-value "Enter profile name..." +"$ui" type citester +"$ui" shot profile-name +"$ui" tap "Create profile" + +"$ui" wait "Continue" 60 +"$ui" shot your-network +"$ui" tap "Continue" + +"$ui" wait "Accept" 30 +"$ui" shot network-commitments +"$ui" tap "Accept" + +sleep 5 +"$ui" shot after-onboarding +allow_notifications +"$ui" shot notifications-allowed + +"$ui" wait "Talk to someone" 60 +"$ui" shot chat-list +expect_chat_list reachable + +# ui layout checks that the toolbar moved +"$ui" layout top +"$ui" shot chat-list-top +expect_chat_list top +"$ui" layout reachable +echo "smoke test passed: onboarding reaches the chat list, which shows its onboarding pages and toolbar with the one-hand UI and with the top bar" diff --git a/scripts/ci/ios-sim/ui b/scripts/ci/ios-sim/ui new file mode 100755 index 0000000000..6738dac5c2 --- /dev/null +++ b/scripts/ci/ios-sim/ui @@ -0,0 +1,237 @@ +#!/bin/bash + +# Drives the app in the simulator SIM_UDID with AXe, for the scripted cases and for an agent on the runner. +# Screenshots and accessibility trees go to UI_OUT. +# +# ui tap