Релиз 1.2.8: emoji iOS, fullscreen фото, сохранение в галерею и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Has been cancelled

This commit is contained in:
2026-03-22 02:00:21 +05:00
parent a3973b616e
commit 7a188a2dbc
5 changed files with 365 additions and 88 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.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"

View File

@@ -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 =

View File

@@ -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
@@ -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
Column(
modifier = Modifier
@@ -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
*/

View File

@@ -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 эмодзи (😀)
*/

View File

@@ -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)
)
)
}
}