feat: Update version to 1.0.6 and enhance release notes with new features and improvements
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,7 +1403,7 @@ private fun CollapsingProfileHeader(
|
||||
verified = 2,
|
||||
size = (nameFontSize.value * 0.8f).toInt(),
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = rosettaBadgeBlue
|
||||
badgeTint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user