Релиз 1.2.8: emoji iOS, fullscreen фото, сохранение в галерею и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Some checks failed
Android Kernel Build / build (push) Has been cancelled
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.2.7"
|
||||
val rosettaVersionCode = 29 // Increment on each release
|
||||
val rosettaVersionName = "1.2.8"
|
||||
val rosettaVersionCode = 30 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -17,30 +17,20 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Поиск
|
||||
- Добавлена вкладка Messages в поиске: поиск по тексту сообщений по всем чатам
|
||||
- Реализованы быстрые сниппеты с подсветкой найденного текста и переходом в нужный чат
|
||||
- Добавлены алиасы для Saved Messages в поиске (saved / saved messages / избранное и др.)
|
||||
Emoji и совместимость iOS
|
||||
- Входящие Unicode-эмодзи теперь стабильно рендерятся как наши Apple Emoji
|
||||
- Поддержаны составные эмодзи (ZWJ), флаги, keycap и тон кожи
|
||||
- Добавлена нормализация и fallback-резолв emoji asset кодов (fe0f/fe0e и вариации unified)
|
||||
|
||||
Тэги и навигация
|
||||
- Исправлены клики по @тэгам в сообщениях: теперь открывается чат пользователя
|
||||
- Добавлен устойчивый резолв @username (локальный диалог -> кэш -> сервер)
|
||||
- Устранен конфликт клика по тэгу с контекстным меню пузырька
|
||||
Стабильность
|
||||
- Исправлен краш при вводе обычного текста/пароля в полях после обновления emoji-резолвера
|
||||
|
||||
Чаты и UI
|
||||
- Улучшен пустой экран Saved Messages на обоях: добавлена подложка и повышена читаемость
|
||||
- Стабилизировано отображение verified-бейджа в хедере личного чата
|
||||
- Подправлено положение галочки в сайдбаре
|
||||
- В тёмной теме цвет цифры в бейдже Requests возле бургер-меню приведен к цвету шапки
|
||||
Профиль и визуал
|
||||
- Сделан чуть более тёмный blur-фон за аватаркой в экране чужого профиля для лучшей читаемости
|
||||
|
||||
Темы и обои
|
||||
- Добавлены пары обоев для светлой и темной темы
|
||||
- Обои теперь автоматически синхронизируются при переключении темы
|
||||
- Выбор обоев сохраняется отдельно для light/dark
|
||||
|
||||
Безопасность и система
|
||||
- Если устройство не поддерживает отпечаток пальца, биометрия больше не предлагается
|
||||
- Удалена неиспользуемая зависимость jsoup
|
||||
Полноэкранный просмотр фото
|
||||
- Добавлено kebab-меню в fullscreen фото с действием Save to Gallery
|
||||
- Исправлено перекрытие системных чёрных зон: большие фото теперь корректно адаптируются во вьюпорте
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentValues
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -55,6 +60,7 @@ import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
@@ -215,6 +221,8 @@ fun ImageViewerScreen(
|
||||
|
||||
// UI visibility state
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var showKebabMenu by remember { mutableStateOf(false) }
|
||||
var isSavingToGallery by remember { mutableStateOf(false) }
|
||||
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
||||
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
||||
val imageBitmapCache =
|
||||
@@ -331,7 +339,14 @@ fun ImageViewerScreen(
|
||||
|
||||
// Current image info
|
||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||
val currentCaption = currentImage?.caption ?: ""
|
||||
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
||||
val statusBarsTopInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val navigationBarBottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
// Reserve space for top controls + bottom area so large images never overlap black UI zones.
|
||||
val imageViewportTopInset = statusBarsTopInset + 64.dp
|
||||
val imageViewportBottomInset =
|
||||
navigationBarBottomInset + if (currentCaption.isNotEmpty()) 84.dp else 16.dp
|
||||
|
||||
// Prefetch ближайших изображений, чтобы при свайпе не было спиннера.
|
||||
LaunchedEffect(pagerState.currentPage, images, privateKey) {
|
||||
@@ -374,6 +389,7 @@ fun ImageViewerScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = imageViewportTopInset, bottom = imageViewportBottomInset)
|
||||
.graphicsLayer {
|
||||
alpha = dismissAlpha.value * edgeTapFadeAlpha.value
|
||||
translationY = animatedOffsetY.value
|
||||
@@ -526,6 +542,77 @@ fun ImageViewerScreen(
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Kebab menu (save image)
|
||||
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
|
||||
IconButton(
|
||||
onClick = { showKebabMenu = true },
|
||||
enabled = currentImage != null && !isSavingToGallery
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showKebabMenu,
|
||||
onDismissRequest = { showKebabMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = if (isSavingToGallery) "Saving..." else "Save to Gallery"
|
||||
)
|
||||
},
|
||||
enabled = !isSavingToGallery,
|
||||
onClick = {
|
||||
val imageToSave = currentImage ?: return@DropdownMenuItem
|
||||
showKebabMenu = false
|
||||
if (isSavingToGallery) return@DropdownMenuItem
|
||||
|
||||
scope.launch {
|
||||
isSavingToGallery = true
|
||||
try {
|
||||
val cachedBitmap = getCachedBitmap(imageToSave.attachmentId)
|
||||
val bitmapToSave =
|
||||
cachedBitmap ?: withContext(Dispatchers.IO) {
|
||||
loadBitmapForViewerImage(context, imageToSave, privateKey)
|
||||
}
|
||||
|
||||
if (bitmapToSave != null && cachedBitmap == null) {
|
||||
cacheBitmap(imageToSave.attachmentId, bitmapToSave)
|
||||
}
|
||||
|
||||
val saved =
|
||||
if (bitmapToSave != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
saveBitmapToGallery(context, bitmapToSave, imageToSave)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (saved) "Saved to gallery" else "Failed to save image",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to save image",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} finally {
|
||||
isSavingToGallery = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title and date
|
||||
Column(
|
||||
@@ -554,7 +641,6 @@ fun ImageViewerScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📝 CAPTION BAR - Telegram-style снизу с анимацией
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val currentCaption = currentImage?.caption ?: ""
|
||||
if (currentCaption.isNotEmpty()) {
|
||||
AnimatedVisibility(
|
||||
visible = showControls && animationState == 1 && !isClosing,
|
||||
@@ -972,6 +1058,55 @@ private suspend fun loadBitmapForViewerImage(
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBitmapToGallery(
|
||||
context: Context,
|
||||
bitmap: Bitmap,
|
||||
image: ViewableImage
|
||||
): Boolean {
|
||||
val resolver = context.contentResolver
|
||||
val fileName = buildGalleryFileName(image)
|
||||
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(
|
||||
MediaStore.Images.Media.RELATIVE_PATH,
|
||||
"${Environment.DIRECTORY_PICTURES}/Rosetta"
|
||||
)
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false
|
||||
|
||||
return try {
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output)) {
|
||||
throw IOException("Bitmap compression failed")
|
||||
}
|
||||
} ?: throw IOException("Cannot open output stream")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val finalizeValues =
|
||||
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
|
||||
resolver.update(uri, finalizeValues, null, null)
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildGalleryFileName(image: ViewableImage): String {
|
||||
val formatter = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
val timePart = formatter.format(image.timestamp)
|
||||
val idPart = image.attachmentId.take(8)
|
||||
return "Rosetta_${timePart}_$idPart.jpg"
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное декодирование base64 в Bitmap
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.icu.text.BreakIterator
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Paint
|
||||
@@ -34,8 +35,163 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
|
||||
private data class EmojiRenderMatch(
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
val unified: String
|
||||
)
|
||||
|
||||
private object AppleEmojiAssetResolver {
|
||||
@Volatile
|
||||
private var availableEmojiAssets: Set<String>? = null
|
||||
private val unifiedResolveCache = ConcurrentHashMap<String, String>()
|
||||
private val unresolvedUnifiedCache = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun normalizeUnifiedCode(code: String): String =
|
||||
code.trim().lowercase(Locale.ROOT).replace('_', '-')
|
||||
|
||||
fun resolveUnifiedFromCode(context: Context, code: String): String? {
|
||||
val normalized = normalizeUnifiedCode(code)
|
||||
if (normalized.isBlank()) return null
|
||||
return resolveUnified(context, normalized)
|
||||
}
|
||||
|
||||
fun collectUnicodeMatches(
|
||||
context: Context,
|
||||
text: String,
|
||||
occupiedRanges: List<IntRange> = emptyList()
|
||||
): List<EmojiRenderMatch> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
|
||||
val iterator = BreakIterator.getCharacterInstance(Locale.ROOT)
|
||||
iterator.setText(text)
|
||||
|
||||
val matches = mutableListOf<EmojiRenderMatch>()
|
||||
var start = iterator.first()
|
||||
var end = iterator.next()
|
||||
|
||||
while (end != BreakIterator.DONE) {
|
||||
if (start < end && !isOverlapping(start, end, occupiedRanges)) {
|
||||
val cluster = text.substring(start, end)
|
||||
val unified = resolveUnifiedFromCluster(context, cluster)
|
||||
if (!unified.isNullOrEmpty()) {
|
||||
matches.add(
|
||||
EmojiRenderMatch(
|
||||
start = start,
|
||||
end = end,
|
||||
unified = unified
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
start = end
|
||||
end = iterator.next()
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private fun resolveUnifiedFromCluster(context: Context, cluster: String): String? {
|
||||
if (cluster.isBlank()) return null
|
||||
val codePoints = cluster.codePoints().toArray()
|
||||
if (codePoints.isEmpty()) return null
|
||||
|
||||
val rawUnified = codePoints.joinToString("-") { codePoint ->
|
||||
String.format(Locale.ROOT, "%04x", codePoint)
|
||||
}
|
||||
|
||||
return resolveUnified(context, rawUnified)
|
||||
}
|
||||
|
||||
private fun resolveUnified(context: Context, rawUnified: String): String? {
|
||||
val key = rawUnified.lowercase(Locale.ROOT)
|
||||
unifiedResolveCache[key]?.let { return it }
|
||||
if (unresolvedUnifiedCache.contains(key)) return null
|
||||
|
||||
val resolved = buildUnifiedCandidates(key).firstOrNull { candidate ->
|
||||
hasEmojiAsset(context, candidate)
|
||||
}
|
||||
if (resolved != null) {
|
||||
unifiedResolveCache[key] = resolved
|
||||
} else {
|
||||
unresolvedUnifiedCache.add(key)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun hasEmojiAsset(context: Context, unified: String): Boolean =
|
||||
getAvailableEmojiAssets(context).contains(unified.lowercase(Locale.ROOT))
|
||||
|
||||
private fun getAvailableEmojiAssets(context: Context): Set<String> {
|
||||
availableEmojiAssets?.let { return it }
|
||||
synchronized(this) {
|
||||
availableEmojiAssets?.let { return it }
|
||||
val loaded =
|
||||
context.assets
|
||||
.list("emoji")
|
||||
?.asSequence()
|
||||
?.filter { it.endsWith(".png", ignoreCase = true) }
|
||||
?.map { it.removeSuffix(".png").lowercase(Locale.ROOT) }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
availableEmojiAssets = loaded
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUnifiedCandidates(rawUnified: String): List<String> {
|
||||
val parts = rawUnified.split('-').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (parts.isEmpty()) return emptyList()
|
||||
|
||||
val candidates = LinkedHashSet<String>()
|
||||
fun addCandidate(partList: List<String>) {
|
||||
if (partList.isNotEmpty()) {
|
||||
candidates.add(partList.joinToString("-"))
|
||||
}
|
||||
}
|
||||
|
||||
addCandidate(parts)
|
||||
|
||||
val withoutTextVs = parts.filterNot { it == "fe0e" }
|
||||
addCandidate(withoutTextVs)
|
||||
|
||||
val withoutAnyVs = withoutTextVs.filterNot { it == "fe0f" }
|
||||
addCandidate(withoutAnyVs)
|
||||
|
||||
if (parts.any { it == "fe0e" }) {
|
||||
addCandidate(parts.map { if (it == "fe0e") "fe0f" else it })
|
||||
}
|
||||
|
||||
val keycapNoVs = withoutAnyVs.toMutableList()
|
||||
val keycapIndex = keycapNoVs.indexOf("20e3")
|
||||
if (keycapIndex > 0 && keycapNoVs.getOrNull(keycapIndex - 1) != "fe0f") {
|
||||
keycapNoVs.add(keycapIndex, "fe0f")
|
||||
addCandidate(keycapNoVs)
|
||||
}
|
||||
|
||||
if (!parts.contains("fe0f")) {
|
||||
if (withoutAnyVs.size == 1) {
|
||||
addCandidate(listOf(withoutAnyVs.first(), "fe0f"))
|
||||
}
|
||||
if (withoutAnyVs.size > 1 && withoutAnyVs[0] != "200d" && withoutAnyVs[1] != "200d") {
|
||||
val withVsAfterFirst = withoutAnyVs.toMutableList().apply { add(1, "fe0f") }
|
||||
addCandidate(withVsAfterFirst)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.toList()
|
||||
}
|
||||
|
||||
private fun isOverlapping(start: Int, end: Int, occupiedRanges: List<IntRange>): Boolean =
|
||||
occupiedRanges.any { range ->
|
||||
start < (range.last + 1) && end > range.first
|
||||
}
|
||||
}
|
||||
|
||||
private class TelegramLikeEmojiSpan(
|
||||
emojiDrawable: Drawable,
|
||||
private var sourceFontMetrics: Paint.FontMetricsInt?
|
||||
@@ -204,31 +360,29 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
val cursorPosition = selectionStart
|
||||
|
||||
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
||||
data class EmojiMatch(val start: Int, val end: Int, val unified: String, val isCodeFormat: Boolean)
|
||||
data class EmojiMatch(val start: Int, val end: Int, val unified: String)
|
||||
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||
|
||||
// 1. Ищем :emoji_XXXX: формат
|
||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
||||
while (codeMatcher.find()) {
|
||||
val unified = codeMatcher.group(1) ?: continue
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified, true))
|
||||
val rawCode = codeMatcher.group(1) ?: continue
|
||||
val unified =
|
||||
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||
}
|
||||
|
||||
// 2. Ищем реальные Unicode эмодзи
|
||||
val matcher = EMOJI_PATTERN.matcher(textStr)
|
||||
while (matcher.find()) {
|
||||
val emoji = matcher.group()
|
||||
val start = matcher.start()
|
||||
val end = matcher.end()
|
||||
|
||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
||||
val overlaps = emojiMatches.any {
|
||||
(start >= it.start && start < it.end) ||
|
||||
(end > it.start && end <= it.end)
|
||||
}
|
||||
if (!overlaps) {
|
||||
emojiMatches.add(EmojiMatch(start, end, emojiToUnified(emoji), false))
|
||||
}
|
||||
// 2. Ищем реальные Unicode эмодзи (графемные кластеры, включая ZWJ/flags/skin tones)
|
||||
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||
val unicodeMatches =
|
||||
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||
context = context,
|
||||
text = textStr,
|
||||
occupiedRanges = occupiedRanges
|
||||
)
|
||||
unicodeMatches.forEach { match ->
|
||||
emojiMatches.add(EmojiMatch(match.start, match.end, match.unified))
|
||||
}
|
||||
|
||||
// 3. Обрабатываем все найденные эмодзи
|
||||
@@ -287,19 +441,6 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun emojiToUnified(emoji: String): String {
|
||||
val codePoints = emoji.codePoints().toArray()
|
||||
if (codePoints.isEmpty()) return ""
|
||||
|
||||
val unifiedParts = ArrayList<String>(codePoints.size)
|
||||
for (codePoint in codePoints) {
|
||||
if (codePoint != 0xFE0F) {
|
||||
unifiedParts.add(String.format("%04x", codePoint))
|
||||
}
|
||||
}
|
||||
|
||||
return unifiedParts.joinToString("-")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,29 +803,48 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
val spannable = SpannableStringBuilder(text)
|
||||
|
||||
// Собираем все замены (чтобы не сбить индексы)
|
||||
data class EmojiMatch(val start: Int, val end: Int, val unified: String)
|
||||
data class EmojiMatch(
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
val unified: String,
|
||||
val isCodeFormat: Boolean
|
||||
)
|
||||
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||
|
||||
// 1. Ищем :emoji_XXXX: формат
|
||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
||||
while (codeMatcher.find()) {
|
||||
val unified = codeMatcher.group(1) ?: continue
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||
val rawCode = codeMatcher.group(1) ?: continue
|
||||
val unified =
|
||||
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||
emojiMatches.add(
|
||||
EmojiMatch(
|
||||
start = codeMatcher.start(),
|
||||
end = codeMatcher.end(),
|
||||
unified = unified,
|
||||
isCodeFormat = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Ищем реальные Unicode эмодзи
|
||||
val unicodeMatcher = EMOJI_PATTERN.matcher(text)
|
||||
while (unicodeMatcher.find()) {
|
||||
val emoji = unicodeMatcher.group()
|
||||
val unified = emojiToUnified(emoji)
|
||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
||||
val overlaps = emojiMatches.any {
|
||||
(unicodeMatcher.start() >= it.start && unicodeMatcher.start() < it.end) ||
|
||||
(unicodeMatcher.end() > it.start && unicodeMatcher.end() <= it.end)
|
||||
}
|
||||
if (!overlaps) {
|
||||
emojiMatches.add(EmojiMatch(unicodeMatcher.start(), unicodeMatcher.end(), unified))
|
||||
}
|
||||
// 2. Ищем реальные Unicode эмодзи (включая составные iOS-кластеры)
|
||||
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||
val unicodeMatches =
|
||||
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||
context = context,
|
||||
text = text,
|
||||
occupiedRanges = occupiedRanges
|
||||
)
|
||||
unicodeMatches.forEach { match ->
|
||||
emojiMatches.add(
|
||||
EmojiMatch(
|
||||
start = match.start,
|
||||
end = match.end,
|
||||
unified = match.unified,
|
||||
isCodeFormat = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
||||
@@ -699,8 +859,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
|
||||
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
||||
// Для Unicode эмодзи оставляем символ как есть
|
||||
if (match.end - match.start > 10) {
|
||||
// Это :emoji_XXXX: формат - заменяем на один символ
|
||||
if (match.isCodeFormat) {
|
||||
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
||||
spannable.setSpan(span, match.start, match.start + 1,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
@@ -709,7 +868,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
spannable.setSpan(span, match.start, match.end,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
} else if (match.end - match.start > 10) {
|
||||
} else if (match.isCodeFormat) {
|
||||
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
||||
val unicodeEmoji = unifiedToEmoji(match.unified)
|
||||
if (unicodeEmoji != null) {
|
||||
@@ -867,20 +1026,6 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun emojiToUnified(emoji: String): String {
|
||||
val codePoints = emoji.codePoints().toArray()
|
||||
if (codePoints.isEmpty()) return ""
|
||||
|
||||
val unifiedParts = ArrayList<String>(codePoints.size)
|
||||
for (codePoint in codePoints) {
|
||||
if (codePoint != 0xFE0F) {
|
||||
unifiedParts.add(String.format("%04x", codePoint))
|
||||
}
|
||||
}
|
||||
|
||||
return unifiedParts.joinToString("-")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀)
|
||||
*/
|
||||
|
||||
@@ -1939,6 +1939,13 @@ private fun CollapsingOtherProfileHeader(
|
||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
// Slightly deepen avatar blur in other profile so text/icons stay readable.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.matchParentSize().background(
|
||||
Color.Black.copy(alpha = if (isDarkTheme) 0.12f else 0.04f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user