From 73ec5d77f48f95caae1f5294e981116da934e27e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 24 Feb 2026 09:02:39 +0500 Subject: [PATCH] feat: Update version to 1.0.6 and enhance release notes with new features and improvements --- app/build.gradle.kts | 4 +- .../messenger/data/MessageRepository.kt | 30 ++++- .../rosetta/messenger/data/ReleaseNotes.kt | 18 +-- .../messenger/network/ProtocolManager.kt | 69 ++++++---- .../messenger/ui/auth/SelectAccountScreen.kt | 9 ++ .../messenger/ui/chats/ChatsListScreen.kt | 45 +++++-- .../ui/chats/components/ImageEditorScreen.kt | 23 +++- .../ui/components/BlurredAvatarBackground.kt | 123 +++++++++++++----- .../messenger/ui/settings/AppearanceScreen.kt | 61 ++++++--- .../ui/settings/OtherProfileScreen.kt | 2 +- .../messenger/ui/settings/ProfileScreen.kt | 2 +- 11 files changed, 275 insertions(+), 111 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c497102..39124d9 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.0.5" -val rosettaVersionCode = 5 // Increment on each release +val rosettaVersionName = "1.0.6" +val rosettaVersionCode = 6 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 9839ecb..ca906df 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -238,7 +238,8 @@ class MessageRepository private constructor(private val context: Context) { val encryptedPlainMessage = try { CryptoManager.encryptWithPassword(messageText, privateKey) - } catch (_: Exception) { + } catch (e: Exception) { + android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e) return null } @@ -294,12 +295,23 @@ class MessageRepository private constructor(private val context: Context) { * Called after account initialization, like desktop useUpdateMessage hook */ suspend fun checkAndSendVersionUpdateMessage() { - val account = currentAccount ?: return + val account = currentAccount + if (account == null) { + android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message") + return + } + val privateKey = currentPrivateKey + if (privateKey == null) { + android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message") + return + } val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE) val lastNoticeKey = prefs.getString("lastNoticeKey", "") ?: "" val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}" + android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}") + if (lastNoticeKey != currentKey) { // Delete the previous message for this version (if any) val prevMessageId = prefs.getString("lastNoticeMessageId_$currentVersion", null) @@ -309,10 +321,16 @@ class MessageRepository private constructor(private val context: Context) { } val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) - prefs.edit() - .putString("lastNoticeKey", currentKey) - .putString("lastNoticeMessageId_$currentVersion", messageId) - .apply() + android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId") + if (messageId != null) { + prefs.edit() + .putString("lastNoticeKey", currentKey) + .putString("lastNoticeMessageId_$currentVersion", messageId) + .apply() + android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully") + } else { + android.util.Log.e("ReleaseNotes", "❌ Failed to create update message") + } } } 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 b72ff96..220ecd4 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,15 +17,15 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - - Emoji keyboard in attachment panel with smooth transitions - - File browser — send any file from device storage - - Send gallery photos as files (without compression) - - Camera: pinch-to-zoom, zoom slider, transparent status bar - - Double checkmarks for read photos, files and avatars - - Haptic feedback on swipe-to-reply - - System accounts hidden from forward picker - - Smoother caption bar animations - - Bug fixes and performance improvements + - Исправлена критическая ошибка синхронизации сообщений между ПК и мобильным устройством + - Подтверждение доставки теперь отправляется только после успешной обработки + - Автоматическая синхронизация при возврате из фона + - Анимация сайдбара в стиле Telegram + - Исправлены артефакты на разделителях при анимации + - Улучшено качество блюра аватара на экранах профиля + - Устранены артефакты по краям изображения + - Обновлён цвет шапки и сайдбара в светлой теме + - Белая галочка верификации на экранах профиля """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index e312fb8..a5534ec 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -15,7 +15,6 @@ import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.resume /** @@ -71,8 +70,10 @@ object ProtocolManager { private val _syncInProgress = MutableStateFlow(false) val syncInProgress: StateFlow = _syncInProgress.asStateFlow() @Volatile private var resyncRequiredAfterAccountInit = false - private val inboundPacketTasks = AtomicInteger(0) + // Desktop parity: sequential task queue with Job-based completion tracking + // (replaces AtomicInteger polling with 15s timeout that could lose messages) private val inboundPacketMutex = Mutex() + @Volatile private var lastInboundJob: Job = Job().also { it.complete() } private fun setSyncInProgress(value: Boolean) { syncBatchInProgress = value @@ -149,17 +150,12 @@ object ProtocolManager { */ private fun setupPacketHandlers() { // Обработчик входящих сообщений (0x06) + // Desktop parity: delivery ACK is sent AFTER successful processing, not before. + // Desktop itself never sends PacketDelivery (server auto-generates them), + // but we still notify the sender after we've safely stored the message. waitPacket(0x06) { packet -> val messagePacket = packet as PacketMessage - // ⚡ ВАЖНО: Отправляем подтверждение доставки обратно серверу - // Без этого сервер не будет отправлять следующие сообщения! - val deliveryPacket = PacketDelivery().apply { - messageId = messagePacket.messageId - toPublicKey = messagePacket.fromPublicKey - } - send(deliveryPacket) - launchInboundPacketTask { val repository = messageRepository if (repository == null || !repository.isInitialized()) { @@ -171,8 +167,18 @@ object ProtocolManager { if (!syncBatchInProgress) { repository.updateLastSyncTimestamp(messagePacket.timestamp) } + // ✅ Send delivery ACK only AFTER message is safely stored in DB. + // Skip for own sync messages (no need to ACK yourself). + val ownKey = getProtocol().getPublicKey() + if (messagePacket.fromPublicKey != ownKey) { + val deliveryPacket = PacketDelivery().apply { + messageId = messagePacket.messageId + toPublicKey = messagePacket.fromPublicKey + } + send(deliveryPacket) + } } catch (e: Exception) { - // Silent error handling + android.util.Log.e(TAG, "❌ Message processing failed: ${messagePacket.messageId.take(8)}, err=${e.message}") } } } @@ -324,18 +330,23 @@ object ProtocolManager { } } + /** + * Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue). + * All inbound packet tasks are serialized via mutex. + * lastInboundJob tracks the last submitted job so BATCH_END can await completion + * without an arbitrary timeout (desktop uses `await whenFinish()`). + */ private fun launchInboundPacketTask(block: suspend () -> Unit) { - inboundPacketTasks.incrementAndGet() - scope.launch { + val job = scope.launch { try { - // Preserve packet handling order to avoid read/message races during sync. inboundPacketMutex.withLock { block() } - } finally { - inboundPacketTasks.decrementAndGet() + } catch (e: Exception) { + android.util.Log.e(TAG, "Inbound packet task error", e) } } + lastInboundJob = job } private fun requireResyncAfterAccountInit(reason: String) { @@ -345,11 +356,14 @@ object ProtocolManager { resyncRequiredAfterAccountInit = true } - private suspend fun waitInboundPacketTasks(timeoutMs: Long = 15_000L) { - val deadline = System.currentTimeMillis() + timeoutMs - while (inboundPacketTasks.get() > 0 && System.currentTimeMillis() < deadline) { - delay(25) - } + /** + * Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts. + * Waits for all currently queued inbound packet tasks to complete. + * Since tasks are serialized via mutex, awaiting the last job + * guarantees all previous jobs have finished. + */ + private suspend fun whenInboundTasksFinish() { + lastInboundJob.join() } private fun onAuthenticated() { @@ -397,6 +411,12 @@ object ProtocolManager { send(packet) } + /** + * Desktop parity: useSynchronize.ts usePacket(25, ...) + * BATCH_START → mark sync in progress + * BATCH_END → wait for ALL message tasks to finish, save timestamp, request next batch + * NOT_NEEDED → sync complete, save timestamp, mark connected + */ private fun handleSyncPacket(packet: PacketSync) { scope.launch { when (packet.status) { @@ -405,7 +425,10 @@ object ProtocolManager { } SyncStatus.BATCH_END -> { setSyncInProgress(true) - waitInboundPacketTasks() + // Desktop: await whenFinish() — wait for ALL queued tasks without timeout. + // Old code used 15s polling timeout which could advance the sync timestamp + // before all messages were processed, causing message loss on app crash. + whenInboundTasksFinish() messageRepository?.updateLastSyncTimestamp(packet.timestamp) sendSynchronize(packet.timestamp) } @@ -677,7 +700,6 @@ object ProtocolManager { _devices.value = emptyList() _pendingDeviceVerification.value = null setSyncInProgress(false) - inboundPacketTasks.set(0) } /** @@ -689,7 +711,6 @@ object ProtocolManager { _devices.value = emptyList() _pendingDeviceVerification.value = null setSyncInProgress(false) - inboundPacketTasks.set(0) scope.cancel() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index 3ef8b14..6f91b02 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -92,6 +92,15 @@ fun SelectAccountScreen( LaunchedEffect(Unit) { visible = true } + + // Status bar icons: black on light theme, white on dark + val view = androidx.compose.ui.platform.LocalView.current + DisposableEffect(isDarkTheme) { + val window = (view.context as android.app.Activity).window + val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !isDarkTheme + onDispose { } + } Box( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index c072d01..509555f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -653,7 +653,7 @@ fun ChatsListScreen( modifier = Modifier .matchParentSize() .background( - if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6) ) ) @@ -1117,7 +1117,36 @@ fun ChatsListScreen( } } ) { - Box(modifier = Modifier.fillMaxSize()) { + // ═══════════════════════════════════════════════════════════ + // Telegram-style scale animation: main content shrinks when drawer opens + // ═══════════════════════════════════════════════════════════ + val drawerWidthPx = with(LocalDensity.current) { 300.dp.toPx() } + val translateXPx = with(LocalDensity.current) { 4.dp.toPx() } + val density = LocalDensity.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .graphicsLayer { + @Suppress("DEPRECATION") + val offset = drawerState.offset.value + val progress = if (offset.isNaN()) 0f + else ((drawerWidthPx + offset) / drawerWidthPx).coerceIn(0f, 1f) + + if (progress > 0.001f) { + val s = 1f - 0.07f * progress + scaleX = s + scaleY = s + translationX = translateXPx * progress + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(1f, 0f) + val cornerPx = with(density) { (16.dp * progress).toPx() } + shape = RoundedCornerShape(cornerPx) + clip = true + compositingStrategy = CompositingStrategy.Offscreen + } + } + ) { Scaffold( topBar = { key(isDarkTheme, showRequestsScreen, isSelectionMode) { @@ -1247,8 +1276,8 @@ fun ChatsListScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4), - scrolledContainerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4), + containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6), + scrolledContainerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6), navigationIconContentColor = Color.White, titleContentColor = Color.White, actionIconContentColor = Color.White @@ -1320,7 +1349,7 @@ fun ChatsListScreen( .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) .clip(badgeShape) .background( - color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6) ) .padding(2.dp) .clip(badgeShape) @@ -1331,7 +1360,7 @@ fun ChatsListScreen( text = badgeText, fontSize = 10.sp, fontWeight = FontWeight.Bold, - color = Color(0xFF0D8CF4), + color = Color(0xFF228BE6), lineHeight = 10.sp, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) ) @@ -1420,9 +1449,9 @@ fun ChatsListScreen( colors = TopAppBarDefaults.topAppBarColors( containerColor = - if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4), + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6), scrolledContainerColor = - if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4), + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6), navigationIconContentColor = Color.White, titleContentColor = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index f9827d6..7a17132 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -197,8 +197,8 @@ fun ImageEditorScreen( animationProgress.animateTo( targetValue = 1f, animationSpec = tween( - durationMillis = 250, - easing = FastOutSlowInEasing + durationMillis = 300, + easing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) ) ) } @@ -417,6 +417,7 @@ fun ImageEditorScreen( Box( modifier = Modifier .fillMaxSize() + .graphicsLayer { alpha = animatedBackgroundAlpha } .background(Color.Black) .zIndex(100f) .pointerInput(Unit) { @@ -429,7 +430,17 @@ fun ImageEditorScreen( Box( modifier = Modifier.fillMaxSize() - .graphicsLayer { alpha = animationProgress.value } + .graphicsLayer { + scaleX = animatedScale + scaleY = animatedScale + translationX = animatedTranslationX + translationY = animatedTranslationY + if (animatedCornerRadius > 0f) { + shape = RoundedCornerShape(animatedCornerRadius) + clip = true + } + alpha = animationProgress.value + } ) { // ═══════════════════════════════════════════════════════════ // 📸 FULLSCREEN PHOTO - занимает ВЕСЬ экран, не реагирует на клавиатуру @@ -443,10 +454,10 @@ fun ImageEditorScreen( setPadding(0, 0, 0, 0) setBackgroundColor(android.graphics.Color.BLACK) - // Простой FIT_CENTER - показывает ВСЁ фото, центрирует + // Edge-to-edge: фото заполняет весь экран без чёрных полос source.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - adjustViewBounds = true + scaleType = ImageView.ScaleType.CENTER_CROP + adjustViewBounds = false setPadding(0, 0, 0, 0) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index 13b8054..38147d4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -12,7 +12,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -20,6 +23,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers @@ -29,14 +33,6 @@ import kotlinx.coroutines.withContext * Компонент для отображения размытого фона аватарки * Используется в профиле и сайдбаре * ВАЖНО: Должен вызываться внутри BoxScope чтобы matchParentSize() работал - * - * @param publicKey Публичный ключ пользователя - * @param avatarRepository Репозиторий аватаров - * @param fallbackColor Цвет фона если нет аватарки - * @param blurRadius Радиус размытия (в пикселях) - применяется при обработке - * @param alpha Прозрачность (0.0 - 1.0) - * @param overlayColors Опциональные цвета overlay поверх blur. Если null — стандартное поведение. - * Если 1 цвет — сплошной overlay, если 2+ — градиент. */ @Composable fun BoxScope.BlurredAvatarBackground( @@ -50,16 +46,13 @@ fun BoxScope.BlurredAvatarBackground( ) { val context = LocalContext.current - // Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима) val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: remember { mutableStateOf(emptyList()) } - // Stable key based on content, not list reference — prevents bitmap reset during recomposition val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L } - // Don't reset bitmap to null when key changes — keep showing old blur until new one is ready var originalBitmap by remember { mutableStateOf(null) } var blurredBitmap by remember { mutableStateOf(null) } @@ -72,7 +65,7 @@ fun BoxScope.BlurredAvatarBackground( if (newOriginal != null) { originalBitmap = newOriginal blurredBitmap = withContext(Dispatchers.Default) { - gaussianBlur(context, newOriginal, radius = 25f, passes = 3) + gaussianBlur(context, newOriginal, radius = 20f, passes = 2) } } } else { @@ -82,24 +75,22 @@ fun BoxScope.BlurredAvatarBackground( } // ═══════════════════════════════════════════════════════════ - // Если выбран overlay-цвет — рисуем blur + пастельный overlay - // (повторяет логику ProfileBlurPreview из AppearanceScreen) + // Режим с overlay-цветом — blur + пастельный overlay // ═══════════════════════════════════════════════════════════ if (overlayColors != null && overlayColors.isNotEmpty()) { - // LAYER 1: Blurred avatar underneath (если есть) + // LAYER 1: Blurred avatar (яркий, видный) if (blurredBitmap != null) { Image( bitmap = blurredBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.matchParentSize() - .graphicsLayer { this.alpha = 0.35f }, + .graphicsLayer { this.alpha = 0.85f }, contentScale = ContentScale.Crop ) } - // LAYER 2: Цвет/градиент overlay с пониженной прозрачностью - // С blur-подложкой — 55%, без — 85% (как в AppearanceScreen preview) - val colorAlpha = if (blurredBitmap != null) 0.55f else 0.85f + // LAYER 2: Цвет/градиент overlay + val colorAlpha = if (blurredBitmap != null) 0.4f else 0.85f val overlayMod = if (overlayColors.size == 1) { Modifier.matchParentSize() .background(overlayColors[0].copy(alpha = colorAlpha)) @@ -112,30 +103,58 @@ fun BoxScope.BlurredAvatarBackground( ) } Box(modifier = overlayMod) + + // LAYER 3: Нижний градиент для читаемости текста + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Transparent, + Color.Black.copy(alpha = 0.2f) + ) + ) + ) + ) return } // ═══════════════════════════════════════════════════════════ - // Стандартный режим (нет overlay-цвета) — blur аватарки или fallback - // Повторяет логику AppearanceScreen → ProfileBlurPreview: - // blur 35% alpha + цвет-тинт 30% alpha + // Стандартный режим (нет overlay-цвета) — blur аватарки + // Telegram-style: яркий видный блюр + мягкое затемнение // ═══════════════════════════════════════════════════════════ if (blurredBitmap != null) { + // LAYER 1: Размытая аватарка — яркая и видная Image( bitmap = blurredBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.matchParentSize() - .graphicsLayer { this.alpha = 0.35f }, + .graphicsLayer { this.alpha = 0.9f }, contentScale = ContentScale.Crop ) - // Тонкий цветовой тинт поверх blur (как в AppearanceScreen preview) + // LAYER 2: Лёгкое тонирование цветом аватара Box( modifier = Modifier .matchParentSize() - .background(fallbackColor.copy(alpha = 0.3f)) + .background(fallbackColor.copy(alpha = 0.12f)) + ) + // LAYER 3: Мягкий нижний градиент для текста + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Transparent, + Color.Black.copy(alpha = 0.25f) + ) + ) + ) ) } else { - // Нет фото — fallback: серый в светлой, тёмный в тёмной (как в AppearanceScreen) Box( modifier = Modifier .matchParentSize() @@ -147,17 +166,47 @@ fun BoxScope.BlurredAvatarBackground( } /** - * Proper Gaussian blur via RenderScript — smooth, non-pixelated. - * Scales down to 1/4 for performance, applies ScriptIntrinsicBlur (radius 25 max), - * then repeats for heavier blur. Result stays at 1/4 scale (enough for backgrounds). + * Gaussian blur via RenderScript. + * Pads the image with mirrored edges before blurring to eliminate edge banding artifacts, + * then crops back to original size. */ @Suppress("deprecation") -private fun gaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap { - // Scale down for performance (1/4 res is plenty for a background) - val w = (source.width / 4).coerceAtLeast(8) - val h = (source.height / 4).coerceAtLeast(8) - var current = Bitmap.createScaledBitmap(source, w, h, true) - .copy(Bitmap.Config.ARGB_8888, true) +private fun gaussianBlur(context: Context, source: Bitmap, radius: Float = 20f, passes: Int = 2): Bitmap { + // Scale down for performance + val w = (source.width / 3).coerceAtLeast(32) + val h = (source.height / 3).coerceAtLeast(32) + val scaled = Bitmap.createScaledBitmap(source, w, h, true) + + // Add padding around the image to prevent edge clamping artifacts + val pad = (radius * passes).toInt().coerceAtLeast(8) + val paddedW = w + pad * 2 + val paddedH = h + pad * 2 + val padded = Bitmap.createBitmap(paddedW, paddedH, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(padded) + + // Draw center + canvas.drawBitmap(scaled, pad.toFloat(), pad.toFloat(), null) + + // Mirror edges: left, right, top, bottom strips + for (x in 0 until pad) { + for (y in 0 until h) { + val pixel = scaled.getPixel((pad - x - 1).coerceIn(0, w - 1), y) + padded.setPixel(x, y + pad, pixel) + val pixel2 = scaled.getPixel((w - 1 - (x.coerceIn(0, w - 1))), y) + padded.setPixel(paddedW - 1 - x, y + pad, pixel2) + } + } + for (y in 0 until pad) { + for (x in 0 until paddedW) { + val srcX = (x - pad).coerceIn(0, w - 1) + val pixel = scaled.getPixel(srcX, (pad - y - 1).coerceIn(0, h - 1)) + padded.setPixel(x, y, pixel) + val pixel2 = scaled.getPixel(srcX, (h - 1 - y.coerceIn(0, h - 1))) + padded.setPixel(x, paddedH - 1 - y, pixel2) + } + } + + var current = padded.copy(Bitmap.Config.ARGB_8888, true) val rs = RenderScript.create(context) val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) @@ -175,5 +224,7 @@ private fun gaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, blur.destroy() rs.destroy() - return current + + // Crop back to original size (remove padding) + return Bitmap.createBitmap(current, pad, pad, w, h) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index eef498a..86d4dee 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -353,7 +353,7 @@ private fun ProfileBlurPreview( if (decoded != null) { avatarBitmap = decoded blurredBitmap = withContext(Dispatchers.Default) { - appearanceGaussianBlur(blurContext, decoded, radius = 25f, passes = 3) + appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2) } } } else { @@ -373,15 +373,17 @@ private fun ProfileBlurPreview( .height(280.dp + statusBarHeight) ) { // ═══════════════════════════════════════════════════ - // LAYER 1: Blurred avatar background (как в профиле) + // LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground) // ═══════════════════════════════════════════════════ if (blurredBitmap != null) { + // overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground) + val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f Image( bitmap = blurredBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier .fillMaxSize() - .graphicsLayer { alpha = 0.35f }, + .graphicsLayer { alpha = blurImgAlpha }, contentScale = ContentScale.Crop ) } @@ -400,25 +402,25 @@ private fun ProfileBlurPreview( val overlayMod = if (overlayColors.size == 1) { Modifier .fillMaxSize() - .background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f)) + .background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)) } else { Modifier .fillMaxSize() .background( Brush.linearGradient( colors = overlayColors.map { - it.copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f) + it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f) } ) ) } Box(modifier = overlayMod) } else if (blurredBitmap != null) { - // Стандартный затемняющий overlay (как в профиле) + // Стандартный тинт (идентичен BlurredAvatarBackground) Box( modifier = Modifier .fillMaxSize() - .background(avatarColors.backgroundColor.copy(alpha = 0.3f)) + .background(avatarColors.backgroundColor.copy(alpha = 0.12f)) ) } else { // Нет аватарки и нет overlay — fallback цвет @@ -432,18 +434,17 @@ private fun ProfileBlurPreview( } // ═══════════════════════════════════════════════════ - // LAYER 3: Тонкий нижний градиент-затемнение + // LAYER 3: Нижний градиент (идентичен BlurredAvatarBackground) // ═══════════════════════════════════════════════════ Box( modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .align(Alignment.BottomCenter) + .fillMaxSize() .background( Brush.verticalGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = 0.35f) + Color.Transparent, + Color.Black.copy(alpha = if (selectedId == "none") 0f else 0.2f) ) ) ) @@ -731,13 +732,37 @@ private fun ColorCircleItem( /** * Proper Gaussian blur via RenderScript — smooth, non-pixelated. + * Mirrors edges to eliminate banding artifacts. */ @Suppress("deprecation") -private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap { - val w = (source.width / 4).coerceAtLeast(8) - val h = (source.height / 4).coerceAtLeast(8) - var current = Bitmap.createScaledBitmap(source, w, h, true) - .copy(Bitmap.Config.ARGB_8888, true) +private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Float = 20f, passes: Int = 2): Bitmap { + val w = (source.width / 3).coerceAtLeast(32) + val h = (source.height / 3).coerceAtLeast(32) + val scaled = Bitmap.createScaledBitmap(source, w, h, true) + + // Pad with mirrored edges to eliminate edge banding + val pad = (radius * passes).toInt().coerceAtLeast(8) + val paddedW = w + pad * 2 + val paddedH = h + pad * 2 + val padded = Bitmap.createBitmap(paddedW, paddedH, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(padded) + canvas.drawBitmap(scaled, pad.toFloat(), pad.toFloat(), null) + + for (x in 0 until pad) { + for (y in 0 until h) { + padded.setPixel(x, y + pad, scaled.getPixel((pad - x - 1).coerceIn(0, w - 1), y)) + padded.setPixel(paddedW - 1 - x, y + pad, scaled.getPixel((w - 1 - x.coerceIn(0, w - 1)), y)) + } + } + for (y in 0 until pad) { + for (x in 0 until paddedW) { + val srcX = (x - pad).coerceIn(0, w - 1) + padded.setPixel(x, y, scaled.getPixel(srcX, (pad - y - 1).coerceIn(0, h - 1))) + padded.setPixel(x, paddedH - 1 - y, scaled.getPixel(srcX, (h - 1 - y.coerceIn(0, h - 1)))) + } + } + + var current = padded.copy(Bitmap.Config.ARGB_8888, true) val rs = RenderScript.create(context) val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) @@ -755,6 +780,6 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo blur.destroy() rs.destroy() - return current + return Bitmap.createBitmap(current, pad, pad, w, h) } 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 dc22d3d..1b63af7 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 @@ -1936,7 +1936,7 @@ private fun CollapsingOtherProfileHeader( verified = if (verified > 0) verified else 1, size = (nameFontSize.value * 0.8f).toInt(), isDarkTheme = isDarkTheme, - badgeTint = if (isRosettaOfficial) rosettaBadgeBlue else null + badgeTint = Color.White ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 6a03420..e057ce6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -1403,7 +1403,7 @@ private fun CollapsingProfileHeader( verified = 2, size = (nameFontSize.value * 0.8f).toInt(), isDarkTheme = isDarkTheme, - badgeTint = rosettaBadgeBlue + badgeTint = Color.White ) } }