Релиз 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
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.2.7"
|
val rosettaVersionName = "1.2.8"
|
||||||
val rosettaVersionCode = 29 // Increment on each release
|
val rosettaVersionCode = 30 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -17,30 +17,20 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Поиск
|
Emoji и совместимость iOS
|
||||||
- Добавлена вкладка Messages в поиске: поиск по тексту сообщений по всем чатам
|
- Входящие Unicode-эмодзи теперь стабильно рендерятся как наши Apple Emoji
|
||||||
- Реализованы быстрые сниппеты с подсветкой найденного текста и переходом в нужный чат
|
- Поддержаны составные эмодзи (ZWJ), флаги, keycap и тон кожи
|
||||||
- Добавлены алиасы для Saved Messages в поиске (saved / saved messages / избранное и др.)
|
- Добавлена нормализация и fallback-резолв emoji asset кодов (fe0f/fe0e и вариации unified)
|
||||||
|
|
||||||
Тэги и навигация
|
Стабильность
|
||||||
- Исправлены клики по @тэгам в сообщениях: теперь открывается чат пользователя
|
- Исправлен краш при вводе обычного текста/пароля в полях после обновления emoji-резолвера
|
||||||
- Добавлен устойчивый резолв @username (локальный диалог -> кэш -> сервер)
|
|
||||||
- Устранен конфликт клика по тэгу с контекстным меню пузырька
|
|
||||||
|
|
||||||
Чаты и UI
|
Профиль и визуал
|
||||||
- Улучшен пустой экран Saved Messages на обоях: добавлена подложка и повышена читаемость
|
- Сделан чуть более тёмный blur-фон за аватаркой в экране чужого профиля для лучшей читаемости
|
||||||
- Стабилизировано отображение verified-бейджа в хедере личного чата
|
|
||||||
- Подправлено положение галочки в сайдбаре
|
|
||||||
- В тёмной теме цвет цифры в бейдже Requests возле бургер-меню приведен к цвету шапки
|
|
||||||
|
|
||||||
Темы и обои
|
Полноэкранный просмотр фото
|
||||||
- Добавлены пары обоев для светлой и темной темы
|
- Добавлено kebab-меню в fullscreen фото с действием Save to Gallery
|
||||||
- Обои теперь автоматически синхронизируются при переключении темы
|
- Исправлено перекрытие системных чёрных зон: большие фото теперь корректно адаптируются во вьюпорте
|
||||||
- Выбор обоев сохраняется отдельно для light/dark
|
|
||||||
|
|
||||||
Безопасность и система
|
|
||||||
- Если устройство не поддерживает отпечаток пальца, биометрия больше не предлагается
|
|
||||||
- Удалена неиспользуемая зависимость jsoup
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ContentValues
|
||||||
import android.graphics.Bitmap
|
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.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
@@ -55,6 +60,7 @@ import compose.icons.tablericons.*
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -215,6 +221,8 @@ fun ImageViewerScreen(
|
|||||||
|
|
||||||
// UI visibility state
|
// UI visibility state
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
var showKebabMenu by remember { mutableStateOf(false) }
|
||||||
|
var isSavingToGallery by remember { mutableStateOf(false) }
|
||||||
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
||||||
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
||||||
val imageBitmapCache =
|
val imageBitmapCache =
|
||||||
@@ -331,7 +339,14 @@ fun ImageViewerScreen(
|
|||||||
|
|
||||||
// Current image info
|
// Current image info
|
||||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||||
|
val currentCaption = currentImage?.caption ?: ""
|
||||||
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
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 ближайших изображений, чтобы при свайпе не было спиннера.
|
// Prefetch ближайших изображений, чтобы при свайпе не было спиннера.
|
||||||
LaunchedEffect(pagerState.currentPage, images, privateKey) {
|
LaunchedEffect(pagerState.currentPage, images, privateKey) {
|
||||||
@@ -374,6 +389,7 @@ fun ImageViewerScreen(
|
|||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(top = imageViewportTopInset, bottom = imageViewportBottomInset)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = dismissAlpha.value * edgeTapFadeAlpha.value
|
alpha = dismissAlpha.value * edgeTapFadeAlpha.value
|
||||||
translationY = animatedOffsetY.value
|
translationY = animatedOffsetY.value
|
||||||
@@ -527,6 +543,77 @@ fun ImageViewerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Title and date
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -554,7 +641,6 @@ fun ImageViewerScreen(
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📝 CAPTION BAR - Telegram-style снизу с анимацией
|
// 📝 CAPTION BAR - Telegram-style снизу с анимацией
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val currentCaption = currentImage?.caption ?: ""
|
|
||||||
if (currentCaption.isNotEmpty()) {
|
if (currentCaption.isNotEmpty()) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls && animationState == 1 && !isClosing,
|
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
|
* Безопасное декодирование base64 в Bitmap
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.icu.text.BreakIterator
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
@@ -34,8 +35,163 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.regex.Pattern
|
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(
|
private class TelegramLikeEmojiSpan(
|
||||||
emojiDrawable: Drawable,
|
emojiDrawable: Drawable,
|
||||||
private var sourceFontMetrics: Paint.FontMetricsInt?
|
private var sourceFontMetrics: Paint.FontMetricsInt?
|
||||||
@@ -204,31 +360,29 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
val cursorPosition = selectionStart
|
val cursorPosition = selectionStart
|
||||||
|
|
||||||
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
// 🔥 Собираем все позиции эмодзи (и 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>()
|
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||||
|
|
||||||
// 1. Ищем :emoji_XXXX: формат
|
// 1. Ищем :emoji_XXXX: формат
|
||||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
||||||
while (codeMatcher.find()) {
|
while (codeMatcher.find()) {
|
||||||
val unified = codeMatcher.group(1) ?: continue
|
val rawCode = codeMatcher.group(1) ?: continue
|
||||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified, true))
|
val unified =
|
||||||
|
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||||
|
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||||
|
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ищем реальные Unicode эмодзи
|
// 2. Ищем реальные Unicode эмодзи (графемные кластеры, включая ZWJ/flags/skin tones)
|
||||||
val matcher = EMOJI_PATTERN.matcher(textStr)
|
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||||
while (matcher.find()) {
|
val unicodeMatches =
|
||||||
val emoji = matcher.group()
|
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||||
val start = matcher.start()
|
context = context,
|
||||||
val end = matcher.end()
|
text = textStr,
|
||||||
|
occupiedRanges = occupiedRanges
|
||||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
)
|
||||||
val overlaps = emojiMatches.any {
|
unicodeMatches.forEach { match ->
|
||||||
(start >= it.start && start < it.end) ||
|
emojiMatches.add(EmojiMatch(match.start, match.end, match.unified))
|
||||||
(end > it.start && end <= it.end)
|
|
||||||
}
|
|
||||||
if (!overlaps) {
|
|
||||||
emojiMatches.add(EmojiMatch(start, end, emojiToUnified(emoji), false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Обрабатываем все найденные эмодзи
|
// 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)
|
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>()
|
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||||
|
|
||||||
// 1. Ищем :emoji_XXXX: формат
|
// 1. Ищем :emoji_XXXX: формат
|
||||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
||||||
while (codeMatcher.find()) {
|
while (codeMatcher.find()) {
|
||||||
val unified = codeMatcher.group(1) ?: continue
|
val rawCode = codeMatcher.group(1) ?: continue
|
||||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
val unified =
|
||||||
|
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||||
|
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||||
|
emojiMatches.add(
|
||||||
|
EmojiMatch(
|
||||||
|
start = codeMatcher.start(),
|
||||||
|
end = codeMatcher.end(),
|
||||||
|
unified = unified,
|
||||||
|
isCodeFormat = true
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ищем реальные Unicode эмодзи
|
// 2. Ищем реальные Unicode эмодзи (включая составные iOS-кластеры)
|
||||||
val unicodeMatcher = EMOJI_PATTERN.matcher(text)
|
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||||
while (unicodeMatcher.find()) {
|
val unicodeMatches =
|
||||||
val emoji = unicodeMatcher.group()
|
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||||
val unified = emojiToUnified(emoji)
|
context = context,
|
||||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
text = text,
|
||||||
val overlaps = emojiMatches.any {
|
occupiedRanges = occupiedRanges
|
||||||
(unicodeMatcher.start() >= it.start && unicodeMatcher.start() < it.end) ||
|
)
|
||||||
(unicodeMatcher.end() > it.start && unicodeMatcher.end() <= it.end)
|
unicodeMatches.forEach { match ->
|
||||||
}
|
emojiMatches.add(
|
||||||
if (!overlaps) {
|
EmojiMatch(
|
||||||
emojiMatches.add(EmojiMatch(unicodeMatcher.start(), unicodeMatcher.end(), unified))
|
start = match.start,
|
||||||
}
|
end = match.end,
|
||||||
|
unified = match.unified,
|
||||||
|
isCodeFormat = false
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
||||||
@@ -699,8 +859,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
||||||
// Для Unicode эмодзи оставляем символ как есть
|
// Для Unicode эмодзи оставляем символ как есть
|
||||||
if (match.end - match.start > 10) {
|
if (match.isCodeFormat) {
|
||||||
// Это :emoji_XXXX: формат - заменяем на один символ
|
|
||||||
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
||||||
spannable.setSpan(span, match.start, match.start + 1,
|
spannable.setSpan(span, match.start, match.start + 1,
|
||||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
@@ -709,7 +868,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
spannable.setSpan(span, match.start, match.end,
|
spannable.setSpan(span, match.start, match.end,
|
||||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
}
|
}
|
||||||
} else if (match.end - match.start > 10) {
|
} else if (match.isCodeFormat) {
|
||||||
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
||||||
val unicodeEmoji = unifiedToEmoji(match.unified)
|
val unicodeEmoji = unifiedToEmoji(match.unified)
|
||||||
if (unicodeEmoji != null) {
|
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 эмодзи (😀)
|
* 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1939,6 +1939,13 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||||
isDarkTheme = isDarkTheme
|
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