diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42f765e..a38218b 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.1.5" -val rosettaVersionCode = 17 // Increment on each release +val rosettaVersionName = "1.1.6" +val rosettaVersionCode = 18 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 6716fe4..1d23041 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -72,6 +72,8 @@ class MainActivity : FragmentActivity() { companion object { private const val TAG = "MainActivity" + // Process-memory session cache: lets app return without password while process is alive. + private var cachedDecryptedAccount: DecryptedAccount? = null // 🔔 FCM Логи для отображения в UI private val _fcmLogs = mutableStateListOf() @@ -87,8 +89,18 @@ class MainActivity : FragmentActivity() { } } - fun clearFcmLogs() { - _fcmLogs.clear() + fun clearFcmLogs() { + _fcmLogs.clear() + } + + private fun cacheSessionAccount(account: DecryptedAccount?) { + cachedDecryptedAccount = account + } + + private fun getCachedSessionAccount(): DecryptedAccount? = cachedDecryptedAccount + + private fun clearCachedSessionAccount() { + cachedDecryptedAccount = null } } @@ -152,14 +164,12 @@ class MainActivity : FragmentActivity() { var showSplash by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) } var hasExistingAccount by remember { mutableStateOf(null) } - var currentAccount by remember { mutableStateOf(null) } + var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } var accountInfoList by remember { mutableStateOf>(emptyList()) } var startCreateAccountFlow by remember { mutableStateOf(false) } // Check for existing accounts and build AccountInfo list - // Also force logout so user always sees unlock screen on app restart LaunchedEffect(Unit) { - accountManager.logout() // Always start logged out val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() accountInfoList = accounts.map { it.toAccountInfo() } @@ -239,6 +249,7 @@ class MainActivity : FragmentActivity() { onAuthComplete = { account -> startCreateAccountFlow = false currentAccount = account + cacheSessionAccount(account) hasExistingAccount = true // Save as last logged account account?.let { @@ -256,6 +267,7 @@ class MainActivity : FragmentActivity() { // Set currentAccount to null immediately to prevent UI // lag currentAccount = null + clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager .disconnect() @@ -283,6 +295,7 @@ class MainActivity : FragmentActivity() { // Set currentAccount to null immediately to prevent UI // lag currentAccount = null + clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager .disconnect() @@ -315,6 +328,7 @@ class MainActivity : FragmentActivity() { hasExistingAccount = accounts.isNotEmpty() // 8. Navigate away last currentAccount = null + clearCachedSessionAccount() } catch (e: Exception) { android.util.Log.e("DeleteAccount", "Failed to delete account", e) } @@ -353,6 +367,7 @@ class MainActivity : FragmentActivity() { // 9. If current account is deleted, return to main login screen if (currentAccount?.publicKey == targetPublicKey) { currentAccount = null + clearCachedSessionAccount() } } catch (e: Exception) { android.util.Log.e("DeleteAccount", "Failed to delete account from sidebar", e) @@ -367,6 +382,7 @@ class MainActivity : FragmentActivity() { // Switch to another account: logout current, then show unlock. currentAccount = null + clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() @@ -375,6 +391,7 @@ class MainActivity : FragmentActivity() { onAddAccount = { startCreateAccountFlow = true currentAccount = null + clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() @@ -387,6 +404,7 @@ class MainActivity : FragmentActivity() { isDarkTheme = isDarkTheme, onExit = { currentAccount = null + clearCachedSessionAccount() scope.launch { ProtocolManager.disconnect() accountManager.logout() 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 567a7d7..855a028 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,17 +17,18 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Подключение - - Ускорен старт соединения и handshake при входе в аккаунт - - Логика reconnect синхронизирована с desktop-поведением - - Обновлён серверный endpoint на основной production (wss) + Профили и аватарки + - Добавлен полноэкранный просмотр аватарок в чужом профиле (включая системные аккаунты) + - Исправлено отображение даты аватарки: устранён некорректный год (например, 58154) - Группы - - Добавлено предзагруженное кэширование участников группы - - Убран скачок "0 members" при повторном открытии группы + Сессии и вход + - Добавлен кэш сессии в памяти процесса: повторный пароль не запрашивается, пока процесс жив + - Кэш сессии корректно очищается при выходе, переключении и удалении аккаунта Интерфейс - - Исправлено вертикальное выравнивание verified-галочки в списке чатов + - Исправлено центрирование blur-фона у системных аватарок + - Унифицировано определение темы для verified-галочек + - В списке чатов verified-галочки сделаны синими в светлой теме (включая system light) """.trimIndent() fun getNotice(version: String): String = 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 90141d7..c26f04b 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 @@ -935,7 +935,7 @@ fun ChatsListScreen( VerifiedBadge( verified = if (accountVerified > 0) accountVerified else 1, size = 15, - badgeTint = Color(0xFFACD2F9) + badgeTint = PrimaryBlue ) } } @@ -3891,7 +3891,9 @@ fun DialogItemContent( VerifiedBadge( verified = if (dialog.verified > 0) dialog.verified else 1, size = 16, - modifier = Modifier.offset(y = (-2).dp) + modifier = Modifier.offset(y = (-2).dp), + isDarkTheme = isDarkTheme, + badgeTint = PrimaryBlue ) } // 🔒 Красная иконка замочка для заблокированных пользователей 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 7838063..d67581f 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 @@ -2,6 +2,11 @@ package com.rosetta.messenger.ui.components import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader import android.os.Build import android.renderscript.Allocation import android.renderscript.Element @@ -32,6 +37,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.math.abs /** * Компонент для отображения размытого фона аватарки @@ -63,7 +69,11 @@ fun BoxScope.BlurredAvatarBackground( LaunchedEffect(avatarKey, publicKey) { val currentAvatars = avatars val newOriginal = withContext(Dispatchers.IO) { - if (currentAvatars.isNotEmpty()) { + // Keep system account blur source identical to the visible avatar drawable. + // This prevents offset/parallax mismatches when server avatar payload differs. + if (MessageRepository.isSystemAccount(publicKey)) { + loadSystemAvatarBitmap(context, publicKey) + } else if (currentAvatars.isNotEmpty()) { AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data) } else { loadSystemAvatarBitmap(context, publicKey) @@ -183,7 +193,78 @@ private fun loadSystemAvatarBitmap(context: Context, publicKey: String): Bitmap? val drawable = AppCompatResources.getDrawable(context, resId) ?: return null val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 256 val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 256 - return drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888) + val source = drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888) + return recenterSystemBlurSource(source) +} + +private data class FocusCenter(val x: Float, val y: Float) + +private fun recenterSystemBlurSource(source: Bitmap): Bitmap { + val focusCenter = detectSystemFocusCenter(source) ?: return source + val dx = source.width / 2f - focusCenter.x + val dy = source.height / 2f - focusCenter.y + + if (abs(dx) < 0.5f && abs(dy) < 0.5f) return source + + val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + val matrix = Matrix().apply { setTranslate(dx, dy) } + shader.setLocalMatrix(matrix) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.shader = shader + isFilterBitmap = true + isDither = true + } + canvas.drawRect(0f, 0f, source.width.toFloat(), source.height.toFloat(), paint) + return output +} + +private fun detectSystemFocusCenter(source: Bitmap): FocusCenter? { + val width = source.width + val height = source.height + if (width < 2 || height < 2) return null + + val cornerColors = intArrayOf( + source.getPixel(0, 0), + source.getPixel(width - 1, 0), + source.getPixel(0, height - 1), + source.getPixel(width - 1, height - 1) + ) + val bgR = cornerColors.map { android.graphics.Color.red(it) }.average().toFloat() + val bgG = cornerColors.map { android.graphics.Color.green(it) }.average().toFloat() + val bgB = cornerColors.map { android.graphics.Color.blue(it) }.average().toFloat() + + val step = (minOf(width, height) / 140).coerceAtLeast(1) + val threshold = 54f + var weightedX = 0f + var weightedY = 0f + var totalWeight = 0f + + var y = 0 + while (y < height) { + var x = 0 + while (x < width) { + val pixel = source.getPixel(x, y) + val dr = abs(android.graphics.Color.red(pixel) - bgR) + val dg = abs(android.graphics.Color.green(pixel) - bgG) + val db = abs(android.graphics.Color.blue(pixel) - bgB) + val weight = (dr + dg + db) - threshold + if (weight > 0f) { + weightedX += x * weight + weightedY += y * weight + totalWeight += weight + } + x += step + } + y += step + } + + if (totalWeight <= 0f) return null + return FocusCenter( + x = weightedX / totalWeight, + y = weightedY / totalWeight + ) } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt index 497a8ef..190c391 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt @@ -1,7 +1,6 @@ package com.rosetta.messenger.ui.components import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* @@ -16,6 +15,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.rosetta.messenger.R import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.theme.LocalRosettaIsDarkTheme /** * Значок верификации пользователя @@ -23,14 +23,14 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue * * @param verified Уровень верификации (0 = нет, 1 = стандартная, 2+ = особая) * @param size Размер значка в dp - * @param isDarkTheme Тема приложения (если не передано - используется системная) + * @param isDarkTheme Тема приложения (если не передано - вычисляется из MaterialTheme) */ @Composable fun VerifiedBadge( verified: Int, size: Int = 16, modifier: Modifier = Modifier, - isDarkTheme: Boolean = isSystemInDarkTheme(), + isDarkTheme: Boolean = LocalRosettaIsDarkTheme.current, badgeTint: Color? = null ) { if (verified <= 0) return 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 192798b..de5afd4 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 @@ -19,6 +19,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.BorderStroke @@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged @@ -192,6 +194,9 @@ fun OtherProfileScreen( var isBlocked by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) } + var showAvatarViewer by remember { mutableStateOf(false) } + var avatarViewerBitmap by remember { mutableStateOf(null) } + var avatarViewerTimestamp by remember { mutableStateOf(0L) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } val pagerState = rememberPagerState( @@ -212,12 +217,12 @@ fun OtherProfileScreen( snapshotFlow { pagerState.currentPage }.collect { page -> selectedTab = OtherProfileTab.entries[page] // Swipe-back only on first tab (Media); on other tabs pager handles swipe - onSwipeBackEnabledChanged(page == 0 && !showImageViewer) + onSwipeBackEnabledChanged(page == 0 && !showImageViewer && !showAvatarViewer) } } - LaunchedEffect(showImageViewer) { - onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0) + LaunchedEffect(showImageViewer, showAvatarViewer) { + onSwipeBackEnabledChanged(!showImageViewer && !showAvatarViewer && pagerState.currentPage == 0) } val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) @@ -1123,6 +1128,43 @@ fun OtherProfileScreen( } } }, + onAvatarLongPress = { + coroutineScope.launch { + when { + hasAvatar && avatars.isNotEmpty() -> { + val first = avatars.first() + avatarViewerTimestamp = first.timestamp + val bitmap = withContext(Dispatchers.IO) { + AvatarFileManager.base64ToBitmap(first.base64Data) + } + if (bitmap != null) { + avatarViewerBitmap = bitmap + showAvatarViewer = true + } + } + user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY -> { + val bitmap = withContext(Dispatchers.IO) { + BitmapFactory.decodeResource(context.resources, R.drawable.safe_account) + } + if (bitmap != null) { + avatarViewerTimestamp = 0L + avatarViewerBitmap = bitmap + showAvatarViewer = true + } + } + user.publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY -> { + val bitmap = withContext(Dispatchers.IO) { + BitmapFactory.decodeResource(context.resources, R.drawable.updates_account) + } + if (bitmap != null) { + avatarViewerTimestamp = 0L + avatarViewerBitmap = bitmap + showAvatarViewer = true + } + } + } + } + }, backgroundBlurColorId = backgroundBlurColorId ) @@ -1135,6 +1177,16 @@ fun OtherProfileScreen( isDarkTheme = isDarkTheme ) } + + FullScreenAvatarViewer( + isVisible = showAvatarViewer, + onDismiss = { showAvatarViewer = false }, + displayName = user.title.ifBlank { user.publicKey.take(10) }, + avatarTimestamp = avatarViewerTimestamp, + avatarBitmap = avatarViewerBitmap, + publicKey = user.publicKey, + isDarkTheme = isDarkTheme + ) } } @@ -1815,6 +1867,7 @@ private fun CollapsingOtherProfileHeader( onBlockToggle: () -> Unit, avatarRepository: AvatarRepository? = null, onClearChat: () -> Unit, + onAvatarLongPress: () -> Unit = {}, backgroundBlurColorId: String = "avatar" ) { val density = LocalDensity.current @@ -1943,7 +1996,12 @@ private fun CollapsingOtherProfileHeader( shape = RoundedCornerShape(cornerRadius) clip = true } - .background(avatarColors.backgroundColor), + .background(avatarColors.backgroundColor) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { onAvatarLongPress() } + ) + }, contentAlignment = Alignment.Center ) { if (publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY) { 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 8b9af63..a0be4c2 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 @@ -2074,7 +2074,13 @@ fun FullScreenAvatarViewer( val dateText = remember(avatarTimestamp) { if (avatarTimestamp > 0) { val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH) - sdf.format(Date(avatarTimestamp * 1000)) + val normalizedTimestampMs = when { + avatarTimestamp >= 1_000_000_000_000_000_000L -> avatarTimestamp / 1_000_000 + avatarTimestamp >= 1_000_000_000_000_000L -> avatarTimestamp / 1_000 + avatarTimestamp >= 1_000_000_000_000L -> avatarTimestamp + else -> avatarTimestamp * 1_000 + } + sdf.format(Date(normalizedTimestampMs)) } else "" } diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt index 35d7eaf..eab98e9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt @@ -16,6 +16,8 @@ import androidx.core.view.WindowCompat import com.rosetta.messenger.ui.utils.NavigationModeUtils import kotlinx.coroutines.delay +val LocalRosettaIsDarkTheme = staticCompositionLocalOf { true } + private val DarkColorScheme = darkColorScheme( primary = DarkPrimary, secondary = Accent, @@ -78,9 +80,11 @@ fun RosettaAndroidTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalRosettaIsDarkTheme provides darkTheme) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } }