Релиз 1.4.5: стабилизация звонков, фиксы UI
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s

- Звонок не сбрасывается при переподключении WebSocket
- Убрано мелькание "Unknown" при завершении (флаг resetting)
- Фикс placeholderColor в ChatDetailScreen (release build)
- ReleaseNotes.kt обновлён с детальным описанием всех изменений
This commit is contained in:
2026-04-04 15:52:54 +05:00
parent 6886a6cef1
commit 7d4b9a8fc4
8 changed files with 150 additions and 205 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.4"
val rosettaVersionCode = 46 // Increment on each release
val rosettaVersionName = "1.4.5"
val rosettaVersionCode = 47 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -1122,18 +1122,13 @@ fun MainScreen(
accountName = accountName,
accountUsername = accountUsername,
accountVerified = accountVerified,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = {
pushScreen(Screen.GroupSetup)
},
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = {
// TODO: Navigate to calls
},
@@ -1152,9 +1147,6 @@ fun MainScreen(
)
},
onSettingsClick = { pushScreen(Screen.Profile) },
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = { pushScreen(Screen.Search) },
onRequestsClick = { pushScreen(Screen.Requests) },
onNewChat = {
@@ -1166,7 +1158,6 @@ fun MainScreen(
onStartCall = { user ->
startCallWithPermission(user)
},
backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats,
onTogglePin = { opponentKey ->
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }

View File

@@ -18,15 +18,31 @@ object ReleaseNotes {
Update v$VERSION_PLACEHOLDER
Звонки
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете
- Полноэкранный входящий звонок на экране блокировки
- Фикс бесконечного "Exchanging keys" при принятии звонка
- Фикс краша ForegroundService при исходящем звонке
- Кастомный WebRTC с E2EE теперь работает в CI-сборках
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
- Звонок больше не сбрасывается при переподключении WebSocket
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
- Автоматическая привязка аккаунта при принятии звонка из push-уведомления
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
- Убрано мелькание "Unknown" при завершении звонка
- Кнопка Decline теперь работает во всех фазах звонка
- Баннер активного звонка теперь отображается внутри диалога
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
- Защита от фантомных звонков при принятии на другом устройстве
- Корректное освобождение PeerConnection (dispose) при завершении звонка
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
- Диагностические логи звонков и уведомлений в rosettadev1
Уведомления
- Аватарки и имена в уведомлениях
- Настройка отключения аватарок в уведомлениях
- Аватарки и имена пользователей в уведомлениях о сообщениях и звонках
- Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications)
- Сохранение FCM токена в rosettadev1 для диагностики
- Поддержка tokenType и deviceId в push-подписке
Интерфейс
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
- Убраны старые обои, исправлено растяжение превью обоев
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -368,9 +368,6 @@ fun ChatDetailScreen(
// 🎨 Window reference для управления статус баром
val window = remember { (view.context as? Activity)?.window }
// 🔥 Focus state for input
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Emoji picker state
var showEmojiPicker by remember { mutableStateOf(false) }
@@ -539,7 +536,7 @@ fun ChatDetailScreen(
} else {
val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
if (!isOverlayControllingSystemBars && window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
@@ -557,7 +554,7 @@ fun ChatDetailScreen(
DisposableEffect(Unit) {
onDispose {
// Восстановить белые иконки статус-бара для chat list header
if (window != null && view != null) {
if (window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
@@ -571,9 +568,6 @@ fun ChatDetailScreen(
}
}
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null)
@@ -648,47 +642,6 @@ fun ChatDetailScreen(
onDispose { onImageViewerChanged(false) }
}
// <20>📷 Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success && cameraImageUri != null) {
// Очищаем фокус чтобы клавиатура не появилась
keyboardController?.hide()
focusManager.clearFocus()
// Открываем редактор вместо прямой отправки
pendingCameraPhotoUri = cameraImageUri
}
}
// <20> Gallery-as-file launcher (sends images as compressed files, not as photos)
val galleryAsFileLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
scope.launch {
val fileName = MediaUtils.getFileName(context, uri)
val fileSize = MediaUtils.getFileSize(context, uri)
if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
android.widget.Toast.makeText(
context,
"File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
android.widget.Toast.LENGTH_LONG
).show()
return@launch
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
}
}
}
}
// <20>📄 File picker launcher
val filePickerLauncher =
rememberLauncherForActivityResult(
@@ -827,7 +780,6 @@ fun ChatDetailScreen(
// Подключаем к ViewModel
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
@@ -2704,66 +2656,40 @@ fun ChatDetailScreen(
} else if (!isSystemAccount) {
// INPUT BAR
Column {
MessageInputBar(
value = inputText,
onValueChange = {
viewModel
.updateInputText(
it
)
if (it.isNotEmpty() &&
!isSavedMessages
) {
viewModel
.sendTypingIndicator()
}
},
ChatInputBarSection(
viewModel = viewModel,
isSavedMessages = isSavedMessages,
onSend = {
isSendingMessage =
true
viewModel
.sendMessage()
isSendingMessage = true
viewModel.sendMessage()
scope.launch {
delay(100)
listState
.animateScrollToItem(
0
)
listState.animateScrollToItem(0)
delay(300)
isSendingMessage =
false
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor =
backgroundColor,
backgroundColor = backgroundColor,
textColor = textColor,
placeholderColor =
secondaryTextColor,
secondaryTextColor =
secondaryTextColor,
replyMessages =
replyMessages,
isForwardMode =
isForwardMode,
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = {
viewModel
.clearReplyMessages()
viewModel.clearReplyMessages()
},
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
return@MessageInputBar
return@ChatInputBarSection
}
val forwardMessages =
panelMessages.map { msg ->
ForwardManager.ForwardMessage(
messageId =
msg.messageId,
messageId = msg.messageId,
text = msg.text,
timestamp =
msg.timestamp,
isOutgoing =
msg.isOutgoing,
timestamp = msg.timestamp,
isOutgoing = msg.isOutgoing,
senderPublicKey =
msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey
@@ -2793,43 +2719,28 @@ fun ChatDetailScreen(
},
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker =
showEmojiPicker,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = {
showEmojiPicker = it
},
focusRequester =
inputFocusRequester,
coordinator = coordinator,
displayReplyMessages =
displayReplyMessages,
onReplyClick =
scrollToMessage,
onReplyClick = scrollToMessage,
onAttachClick = {
// Telegram-style:
// галерея
// открывается
// ПОВЕРХ клавиатуры
// НЕ скрываем
// клавиатуру!
showMediaPicker =
true
// галерея открывается поверх клавиатуры.
showMediaPicker = true
},
myPublicKey =
viewModel
.myPublicKey
?: "",
opponentPublicKey =
user.publicKey,
myPrivateKey =
currentUserPrivateKey,
viewModel.myPublicKey ?: "",
opponentPublicKey = user.publicKey,
myPrivateKey = currentUserPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger =
inputFocusTrigger,
suppressKeyboard =
showInAppCamera,
inputFocusTrigger = inputFocusTrigger,
suppressKeyboard = showInAppCamera,
hasNativeNavigationBar =
hasNativeNavigationBar
)
@@ -4311,6 +4222,76 @@ fun ChatDetailScreen(
} // Закрытие outer Box
}
@Composable
private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage>,
isForwardMode: Boolean,
onCloseReply: () -> Unit,
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit,
chatTitle: String,
isBlocked: Boolean,
showEmojiPicker: Boolean,
onToggleEmojiPicker: (Boolean) -> Unit,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage>,
onReplyClick: (String) -> Unit,
onAttachClick: () -> Unit,
myPublicKey: String,
opponentPublicKey: String,
myPrivateKey: String,
isGroupChat: Boolean,
mentionCandidates: List<MentionCandidate>,
avatarRepository: AvatarRepository?,
inputFocusTrigger: Int,
suppressKeyboard: Boolean,
hasNativeNavigationBar: Boolean
) {
val inputText by viewModel.inputText.collectAsState()
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = onSend,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = onCloseReply,
onShowForwardOptions = onShowForwardOptions,
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = onToggleEmojiPicker,
coordinator = coordinator,
displayReplyMessages = displayReplyMessages,
onReplyClick = onReplyClick,
onAttachClick = onAttachClick,
myPublicKey = myPublicKey,
opponentPublicKey = opponentPublicKey,
myPrivateKey = myPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger = inputFocusTrigger,
suppressKeyboard = suppressKeyboard,
hasNativeNavigationBar = hasNativeNavigationBar
)
}
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")

View File

@@ -264,24 +264,19 @@ fun ChatsListScreen(
accountName: String,
accountUsername: String,
accountVerified: Int = 0,
accountPhone: String,
accountPublicKey: String,
accountPrivateKey: String = "",
privateKeyHash: String = "",
onToggleTheme: () -> Unit,
onProfileClick: () -> Unit,
onNewGroupClick: () -> Unit,
onContactsClick: () -> Unit,
onCallsClick: () -> Unit,
onSavedMessagesClick: () -> Unit,
onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit,
onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
pinnedChats: Set<String> = emptySet(),
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
@@ -462,7 +457,6 @@ fun ChatsListScreen(
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений
val initStart = System.currentTimeMillis()
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d(
"ChatsListScreen",
@@ -569,10 +563,6 @@ fun ChatsListScreen(
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
}
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
// Header сразу visible = true, без анимации при возврате из чата
var visible by rememberSaveable { mutableStateOf(true) }
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -794,13 +784,6 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════
// 🎨 DRAWER HEADER
// ═══════════════════════════════════════════════════════════
val avatarColors =
getAvatarColor(
accountPublicKey,
isDarkTheme
)
val headerColor = avatarColors.backgroundColor
// Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) {
Box(
@@ -3462,9 +3445,6 @@ fun ChatItem(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
val avatarText = getAvatarText(chat.publicKey)
Column {
Row(
modifier =
@@ -3939,13 +3919,11 @@ fun SwipeableDialogItem(
velocityTracker.resetTracking()
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
var claimed = false
// Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown"
var fingerIsUp = false
val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) {
@@ -3982,19 +3960,17 @@ fun SwipeableDialogItem(
// Timeout → check if finger lifted during the race window
if (result == null) {
// Grace period: check if up event arrived just as timeout fired
val graceResult = withTimeoutOrNull(32L) {
withTimeoutOrNull(32L) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) {
gestureType = "cancelled"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
if (change.changedToUpIgnoreConsumed()) {
change.consume()
gestureType = "tap"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
// Still moving/holding — it's a real long press
@@ -4034,13 +4010,11 @@ fun SwipeableDialogItem(
when {
// Horizontal left swipe — reveal action buttons
currentSwipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
currentOnSwipeStarted()
}
// Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> {
passedSlop = true
claimed = true
}
// Right swipe with buttons closed — let drawer handle
@@ -4144,10 +4118,6 @@ fun DialogItemContent(
val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val avatarColors =
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя
@@ -4182,38 +4152,6 @@ fun DialogItemContent(
}
}
// 📁 Для Saved Messages показываем иконку закладки
// 🔥 Как в Архиве: инициалы из title или username или DELETED
val initials =
remember(
dialog.opponentTitle,
dialog.opponentUsername,
dialog.opponentKey,
dialog.isSavedMessages
) {
if (dialog.isSavedMessages) {
"" // Для Saved Messages - пустая строка, будет использоваться
// иконка
} else if (dialog.opponentTitle.isNotEmpty() &&
dialog.opponentTitle != dialog.opponentKey &&
dialog.opponentTitle != dialog.opponentKey.take(7) &&
dialog.opponentTitle != dialog.opponentKey.take(8)
) {
// Используем title для инициалов
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.ifEmpty { dialog.opponentTitle.take(2).uppercase() }
} else if (dialog.opponentUsername.isNotEmpty()) {
// Если только username - берем первые 2 символа
dialog.opponentUsername.take(2).uppercase()
} else {
dialog.opponentKey.take(2).uppercase()
}
}
Row(
modifier =
Modifier.fillMaxWidth()
@@ -5003,7 +4941,6 @@ private fun RequestsRouteContent(
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = onRequestClick,
avatarRepository = avatarRepository,
blockedUsers = blockedUsers,
@@ -5138,7 +5075,6 @@ fun RequestsSection(
fun RequestsScreen(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
onBack: () -> Unit,
onRequestClick: (DialogUiModel) -> Unit,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
blockedUsers: Set<String> = emptySet(),

View File

@@ -87,7 +87,6 @@ fun RequestsListScreen(
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = { request ->
onUserSelect(chatsViewModel.dialogToSearchUser(request))
},

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
@@ -56,6 +55,7 @@ import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.ChatViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.util.Locale
@@ -89,7 +89,6 @@ fun MessageInputBar(
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
placeholderColor: Color,
secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false,
@@ -99,7 +98,6 @@ fun MessageInputBar(
isBlocked: Boolean = false,
showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {},
focusRequester: FocusRequester? = null,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
onReplyClick: (String) -> Unit = {},
@@ -189,7 +187,9 @@ fun MessageInputBar(
// Update coordinator through snapshotFlow (no recomposition)
LaunchedEffect(Unit) {
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }
.distinctUntilChanged()
.collect { currentImeHeight ->
val now = System.currentTimeMillis()
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
if (heightChanged && currentImeHeight.value > 0) {
@@ -202,9 +202,13 @@ fun MessageInputBar(
isKeyboardVisible = currentImeHeight > 50.dp
coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) {
// Update "stable" height only after IME animation settles,
// otherwise low-end devices get many unnecessary recompositions.
if (!isKeyboardAnimating && currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
lastStableKeyboardHeight = currentImeHeight
}
}
}
}

View File

@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
try {
val textStr = editable.toString()
val hasEmojiHints = containsEmojiHints(textStr)
if (!hasEmojiHints) {
// Fast path for plain text: skip heavy grapheme/asset pipeline.
// Also drop stale spans if user removed emoji content.
editable.getSpans(0, editable.length, ImageSpan::class.java).forEach {
editable.removeSpan(it)
}
return
}
val cursorPosition = selectionStart
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
@@ -430,6 +439,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
}
}
private fun containsEmojiHints(text: String): Boolean {
if (text.isEmpty()) return false
if (text.indexOf(":emoji_") >= 0) return true
for (ch in text) {
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') return true
}
return false
}
private fun loadFromAssets(unified: String): Bitmap? {
return try {
val inputStream = getContext().assets.open("emoji/$unified.png")