feat: Update version to 1.0.6 and enhance release notes with new features and improvements

This commit is contained in:
2026-02-24 09:02:39 +05:00
parent fba95c0516
commit 73ec5d77f4
11 changed files with 275 additions and 111 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.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"

View File

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

View File

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

View File

@@ -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<Boolean> = _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()
}

View File

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

View File

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

View File

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

View File

@@ -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<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(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)
}

View File

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

View File

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

View File

@@ -1403,7 +1403,7 @@ private fun CollapsingProfileHeader(
verified = 2,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = rosettaBadgeBlue
badgeTint = Color.White
)
}
}