From 7a188a2dbc6be4192805c391059c3786ceca88f7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 22 Mar 2026 02:00:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.2.8:=20emoji?= =?UTF-8?q?=20iOS,=20fullscreen=20=D1=84=D0=BE=D1=82=D0=BE,=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20?= =?UTF-8?q?=D0=B3=D0=B0=D0=BB=D0=B5=D1=80=D0=B5=D1=8E=20=D0=B8=20UI-=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../rosetta/messenger/data/ReleaseNotes.kt | 32 +- .../ui/chats/components/ImageViewerScreen.kt | 137 ++++++++- .../ui/components/AppleEmojiEditText.kt | 273 ++++++++++++++---- .../ui/settings/OtherProfileScreen.kt | 7 + 5 files changed, 365 insertions(+), 88 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 53e2e4e..1c4d19b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 80fa5f4..33b02d3 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 94b2702..b11ece4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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 */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 4a9c588..103720a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -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? = null + private val unifiedResolveCache = ConcurrentHashMap() + private val unresolvedUnifiedCache = ConcurrentHashMap.newKeySet() + + 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 = emptyList() + ): List { + if (text.isEmpty()) return emptyList() + + val iterator = BreakIterator.getCharacterInstance(Locale.ROOT) + iterator.setText(text) + + val matches = mutableListOf() + 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 { + 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 { + val parts = rawUnified.split('-').map { it.trim() }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return emptyList() + + val candidates = LinkedHashSet() + fun addCandidate(partList: List) { + 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): 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() // 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(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() // 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(codePoints.size) - for (codePoint in codePoints) { - if (codePoint != 0xFE0F) { - unifiedParts.add(String.format("%04x", codePoint)) - } - } - - return unifiedParts.joinToString("-") - } - /** * 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀) */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index f18cdb1..9023b43 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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) + ) + ) } }