diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6699f1..d67623a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index 799ded2..117492a 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -3,6 +3,7 @@ package com.rosetta.messenger import android.app.Application import com.airbnb.lottie.L import com.rosetta.messenger.data.DraftManager +import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.utils.CrashReportManager /** @@ -26,6 +27,9 @@ class RosettaApplication : Application() { // Инициализируем менеджер черновиков DraftManager.init(this) + // Инициализируем менеджер обновлений (SDU) + UpdateManager.init() + } /** diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketRequestUpdate.kt b/app/src/main/java/com/rosetta/messenger/network/PacketRequestUpdate.kt new file mode 100644 index 0000000..43bd31e --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketRequestUpdate.kt @@ -0,0 +1,23 @@ +package com.rosetta.messenger.network + +/** + * Request Update packet (ID: 0x0A) + * Запрос адреса SDU сервера обновлений с основного сервера. + * Клиент отправляет пакет с пустым updateServer, сервер отвечает тем же пакетом с URL SDU. + */ +class PacketRequestUpdate : Packet() { + var updateServer: String = "" + + override fun getPacketId(): Int = 0x0A + + override fun receive(stream: Stream) { + updateServer = stream.readString() + } + + override fun send(): Stream { + val stream = Stream() + stream.writeInt16(getPacketId()) + stream.writeString(updateServer) + return stream + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 5e1322d..3326972 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -124,6 +124,7 @@ class Protocol( 0x07 to { PacketRead() }, 0x08 to { PacketDelivery() }, 0x09 to { PacketDeviceNew() }, + 0x0A to { PacketRequestUpdate() }, 0x0B to { PacketTyping() }, 0x0F to { PacketRequestTransport() }, 0x17 to { PacketDeviceList() }, 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 a5534ec..969c900 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -369,6 +369,7 @@ object ProtocolManager { private fun onAuthenticated() { setSyncInProgress(false) TransportManager.requestTransportServer() + com.rosetta.messenger.update.UpdateManager.requestSduServer() fetchOwnProfile() requestSynchronize() subscribePushTokenIfAvailable() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 2c1682d..28859c5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -142,7 +143,7 @@ fun ChatDetailScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) - val headerIconColor = if (isDarkTheme) Color(0xFF0A84FF) else Color(0xFF007AFF) + val headerIconColor = Color.White // 🔥 Keyboard & Emoji Coordinator val coordinator = remember { KeyboardTransitionCoordinator() } @@ -232,7 +233,7 @@ fun ChatDetailScreen( if (window != null && view != null) { val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) window.statusBarColor = android.graphics.Color.TRANSPARENT - ic.isAppearanceLightStatusBars = !isDarkTheme + ic.isAppearanceLightStatusBars = false } } } @@ -659,7 +660,7 @@ fun ChatDetailScreen( // 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer) Box(modifier = Modifier.fillMaxSize()) { // Telegram-style solid header background (без blur) - val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + val headerBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6) Scaffold( contentWindowInsets = WindowInsets(0.dp), @@ -669,13 +670,7 @@ fun ChatDetailScreen( Box( modifier = Modifier.fillMaxWidth() - .background( - if (isSelectionMode) { - if (isDarkTheme) - Color(0xFF212121) - else Color.White - } else headerBackground - ) + .background(headerBackground) // 🎨 statusBarsPadding ПОСЛЕ background = // хедер начинается под статус баром .statusBarsPadding() @@ -737,12 +732,7 @@ fun ChatDetailScreen( fontWeight = FontWeight .Bold, - color = - if (isDarkTheme - ) - Color.White - else - Color.Black + color = Color.White ) } @@ -880,14 +870,7 @@ fun ChatDetailScreen( imageVector = TablerIcons.ChevronLeft, contentDescription = "Back", - tint = - if (isDarkTheme - ) - Color.White - else - Color( - 0xFF007AFF - ), + tint = Color.White, modifier = Modifier.size( 28.dp @@ -1007,15 +990,14 @@ fun ChatDetailScreen( CircleShape ) .background( - PrimaryBlue + Color(0xFF228BE6) ), contentAlignment = Alignment .Center ) { Icon( - Icons.Default - .Bookmark, + painter = painterResource(R.drawable.bookmark_outlined), contentDescription = null, tint = @@ -1102,7 +1084,7 @@ fun ChatDetailScreen( FontWeight .SemiBold, color = - textColor, + Color.White, maxLines = 1, overflow = @@ -1125,7 +1107,8 @@ fun ChatDetailScreen( size = 16, isDarkTheme = - isDarkTheme + isDarkTheme, + badgeTint = if (isDarkTheme) null else Color(0xFFACD2F9) ) } // 🔇 Mute icon @@ -1135,7 +1118,7 @@ fun ChatDetailScreen( painter = TelegramIcons.Mute, contentDescription = "Muted", modifier = Modifier.size(16.dp), - tint = secondaryTextColor + tint = Color.White.copy(alpha = 0.7f) ) } } @@ -1155,13 +1138,13 @@ fun ChatDetailScreen( color = when { isSavedMessages -> - secondaryTextColor + Color.White.copy(alpha = 0.7f) isOnline -> Color( - 0xFF38B24D - ) // Зелёный когда онлайн + 0xFF7FE084 + ) // Зелёный когда онлайн (светлый на синем фоне) else -> - secondaryTextColor // Серый + Color.White.copy(alpha = 0.7f) // Белый полупрозрачный // для // offline }, @@ -1182,14 +1165,7 @@ fun ChatDetailScreen( .Call, contentDescription = "Call", - tint = - if (isDarkTheme - ) - Color.White - else - Color( - 0xFF007AFF - ) + tint = Color.White ) } } @@ -1221,14 +1197,7 @@ fun ChatDetailScreen( .MoreVert, contentDescription = "More", - tint = - if (isDarkTheme - ) - Color.White - else - Color( - 0xFF007AFF - ), + tint = Color.White, modifier = Modifier.size( 26.dp @@ -1287,15 +1256,7 @@ fun ChatDetailScreen( .fillMaxWidth() .height(0.5.dp) .background( - if (isDarkTheme) - Color.White.copy( - alpha = - 0.15f - ) - else - Color.Black.copy( - alpha = 0.1f - ) + Color.White.copy(alpha = 0.15f) ) ) } // Закрытие Box unified header 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 509555f..fbfb8b3 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 @@ -73,6 +73,8 @@ import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark +import com.rosetta.messenger.update.UpdateManager +import com.rosetta.messenger.update.UpdateState import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.painter.Painter @@ -245,6 +247,10 @@ fun ChatsListScreen( val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + val sduUpdateState by UpdateManager.updateState.collectAsState() + val sduDownloadProgress by UpdateManager.downloadProgress.collectAsState() + val sduDebugLogs by UpdateManager.debugLogs.collectAsState() + var showSduLogs by remember { mutableStateOf(false) } val themeRevealRadius = remember { Animatable(0f) } var rootSize by remember { mutableStateOf(IntSize.Zero) } var themeToggleCenterInRoot by remember { mutableStateOf(null) } @@ -253,6 +259,73 @@ fun ChatsListScreen( var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } + // ═══════════════ SDU Debug Log Dialog ═══════════════ + if (showSduLogs) { + AlertDialog( + onDismissRequest = { showSduLogs = false }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("SDU Logs", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.weight(1f)) + Text( + "state: ${sduUpdateState::class.simpleName}", + fontSize = 11.sp, + color = Color.Gray + ) + } + }, + text = { + val scrollState = rememberScrollState() + LaunchedEffect(sduDebugLogs.size) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(scrollState) + ) { + if (sduDebugLogs.isEmpty()) { + Text( + "Нет логов. SDU ещё не инициализирован\nили пакет 0x0A не пришёл.", + fontSize = 13.sp, + color = Color.Gray + ) + } else { + sduDebugLogs.forEach { line -> + Text( + text = line, + fontSize = 11.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = when { + "ERROR" in line || "EXCEPTION" in line -> Color(0xFFFF5555) + "WARNING" in line -> Color(0xFFFFAA33) + "State ->" in line -> Color(0xFF55BB55) + else -> if (isDarkTheme) Color(0xFFCCCCCC) else Color(0xFF333333) + }, + modifier = Modifier.padding(vertical = 1.dp) + ) + } + } + } + }, + confirmButton = { + Row { + TextButton(onClick = { + // Retry: force re-request SDU + UpdateManager.requestSduServer() + }) { + Text("Retry SDU") + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = { showSduLogs = false }) { + Text("Close") + } + } + } + ) + } + fun startThemeReveal() { if (themeRevealActive) { return @@ -801,7 +874,7 @@ fun ChatsListScreen( VerifiedBadge( verified = 1, size = 15, - badgeTint = Color.White + badgeTint = Color(0xFFACD2F9) ) } } @@ -1078,9 +1151,15 @@ fun ChatsListScreen( } // ═══════════════════════════════════════════════════════════ - // FOOTER - Version + // FOOTER - Version + Update Banner // ═══════════════════════════════════════════════════════════ - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + // Telegram-style update banner + val curUpdate = sduUpdateState + val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable || + curUpdate is UpdateState.Downloading || + curUpdate is UpdateState.ReadyToInstall + Divider( color = if (isDarkTheme) @@ -1089,29 +1168,137 @@ fun ChatsListScreen( thickness = 0.5.dp ) - // Version info - Box( - modifier = - Modifier.fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ), - contentAlignment = - Alignment.CenterStart - ) { - Text( - text = "Rosetta v${BuildConfig.VERSION_NAME}", - fontSize = 12.sp, - color = - if (isDarkTheme) - Color(0xFF666666) - else - Color(0xFF999999) - ) + // Version info — прячем когда есть баннер обновления + if (!showUpdateBanner) { + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = 20.dp, + vertical = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Rosetta v${BuildConfig.VERSION_NAME}", + fontSize = 12.sp, + color = + if (isDarkTheme) + Color(0xFF666666) + else + Color(0xFF999999) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "(статус: ${sduUpdateState::class.simpleName})", + fontSize = 10.sp, + color = + if (isDarkTheme) + Color(0xFF555555) + else + Color(0xFFAAAAAA) + ) + Spacer(modifier = Modifier.weight(1f)) + // Debug log button + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE) + ) + .clickable { showSduLogs = true }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Bug, + contentDescription = "SDU Logs", + modifier = Modifier.size(16.dp), + tint = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) } - Spacer(modifier = Modifier.height(16.dp)) + if (showUpdateBanner) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background( + Brush.horizontalGradient( + colors = listOf( + Color(0xFF69BF72), + Color(0xFF53B3AD) + ) + ) + ) + .clickable { + when (curUpdate) { + is UpdateState.UpdateAvailable -> + UpdateManager.downloadAndInstall(context) + is UpdateState.Downloading -> + UpdateManager.cancelDownload() + is UpdateState.ReadyToInstall -> + UpdateManager.downloadAndInstall(context) + else -> {} + } + }, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = when (curUpdate) { + is UpdateState.Downloading -> TablerIcons.X + else -> TablerIcons.Download + }, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = when (curUpdate) { + is UpdateState.Downloading -> + "Downloading... ${curUpdate.progress}%" + is UpdateState.ReadyToInstall -> + "Install Update" + is UpdateState.UpdateAvailable -> + "Update Rosetta" + else -> "" + }, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + if (curUpdate is UpdateState.UpdateAvailable) { + Text( + text = curUpdate.version, + color = Color.White.copy(alpha = 0.8f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + if (curUpdate is UpdateState.Downloading) { + CircularProgressIndicator( + progress = curUpdate.progress / 100f, + modifier = Modifier.size(20.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + strokeWidth = 2.dp + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + } } } } @@ -1757,7 +1944,10 @@ fun ChatsListScreen( state = chatListState, modifier = Modifier.fillMaxSize() - .nestedScroll(requestsNestedScroll) + .then( + if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll) + else Modifier + ) .background( listBackgroundColor ) @@ -3384,7 +3574,7 @@ fun DialogItemContent( modifier = Modifier.fillMaxSize() .clip(CircleShape) - .background(PrimaryBlue), + .background(Color(0xFF228BE6)), contentAlignment = Alignment.Center ) { Icon( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index 22bfd52..be6a047 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -347,7 +347,7 @@ private fun ForwardDialogItem( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(PrimaryBlue), + .background(Color(0xFF228BE6)), contentAlignment = Alignment.Center ) { Icon( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt index 167985c..9a938b1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt @@ -18,11 +18,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.airbnb.lottie.compose.* import com.airbnb.lottie.LottieComposition import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.R import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -182,12 +184,12 @@ private fun SearchResultItem( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor), + .background(if (isOwnAccount) Color(0xFF228BE6) else avatarColors.backgroundColor), contentAlignment = Alignment.Center ) { if (isOwnAccount) { Icon( - Icons.Default.Bookmark, + painter = painterResource(R.drawable.bookmark_outlined), contentDescription = "Saved Messages", tint = Color.White, modifier = Modifier.size(20.dp) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 8fe72d1..8dba100 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.AvatarImage +import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.database.RosettaDatabase import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -438,11 +439,9 @@ private fun RecentUserItem( ) if (user.verified != 0) { Spacer(modifier = Modifier.width(4.dp)) - Icon( - Icons.Default.Verified, - contentDescription = "Verified", - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) + VerifiedBadge( + verified = user.verified, + size = 16 ) } } 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 363b08b..f9fb51d 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 @@ -4,16 +4,17 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Verified import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import com.rosetta.messenger.R +import com.rosetta.messenger.ui.onboarding.PrimaryBlue /** * Значок верификации пользователя @@ -35,14 +36,9 @@ fun VerifiedBadge( var showDialog by remember { mutableStateOf(false) } - // Цвет в зависимости от уровня верификации + // Цвет верификации: в тёмной теме — как индикаторы прочтения (PrimaryBlue), в светлой — #ACD2F9 val badgeColor = - badgeTint - ?: when (verified) { - 1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram) - 2 -> Color(0xFFFFD700) // Золотая верификация - else -> Color(0xFF4CAF50) // Зеленая для других уровней - } + badgeTint ?: if (isDarkTheme) PrimaryBlue else Color(0xFFACD2F9) // Текст аннотации val annotationText = when (verified) { @@ -52,7 +48,7 @@ fun VerifiedBadge( } Icon( - Icons.Default.Verified, + painter = painterResource(id = R.drawable.ic_rosette_discount_check), contentDescription = "Verified", tint = badgeColor, modifier = modifier @@ -73,7 +69,7 @@ fun VerifiedBadge( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - Icons.Default.Verified, + painter = painterResource(id = R.drawable.ic_rosette_discount_check), contentDescription = null, tint = badgeColor, modifier = Modifier.size(32.dp) 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 1b63af7..6ffa9fd 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 @@ -1935,8 +1935,7 @@ private fun CollapsingOtherProfileHeader( VerifiedBadge( verified = if (verified > 0) verified else 1, size = (nameFontSize.value * 0.8f).toInt(), - isDarkTheme = isDarkTheme, - badgeTint = Color.White + isDarkTheme = isDarkTheme ) } } 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 e057ce6..d2d1eae 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 @@ -1402,8 +1402,7 @@ private fun CollapsingProfileHeader( VerifiedBadge( verified = 2, size = (nameFontSize.value * 0.8f).toInt(), - isDarkTheme = isDarkTheme, - badgeTint = Color.White + isDarkTheme = isDarkTheme ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt index fc64bfd..a382377 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt @@ -7,20 +7,21 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* -import com.rosetta.messenger.ui.icons.TelegramIcons import compose.icons.TablerIcons import compose.icons.tablericons.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.BuildConfig +import com.rosetta.messenger.update.UpdateManager +import com.rosetta.messenger.update.UpdateState +import kotlinx.coroutines.launch @Composable fun UpdatesScreen( @@ -28,6 +29,8 @@ fun UpdatesScreen( onBack: () -> Unit ) { val view = LocalView.current + val context = LocalContext.current + if (!view.isInEditMode) { SideEffect { val window = (view.context as android.app.Activity).window @@ -43,8 +46,11 @@ fun UpdatesScreen( val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - - // Handle back gesture + + val updateState by UpdateManager.updateState.collectAsState() + val downloadProgress by UpdateManager.downloadProgress.collectAsState() + val coroutineScope = rememberCoroutineScope() + BackHandler { onBack() } Column( @@ -87,29 +93,12 @@ fun UpdatesScreen( .verticalScroll(rememberScrollState()) .padding(16.dp) ) { - // Info Card - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFF2E7D32).copy(alpha = 0.2f), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "✓ App is up to date", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF2E7D32) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "You're using the latest version", - fontSize = 12.sp, - color = secondaryTextColor - ) - } - } + // Status Card + UpdateStatusCard( + state = updateState, + progress = downloadProgress, + secondaryTextColor = secondaryTextColor + ) Spacer(modifier = Modifier.height(24.dp)) @@ -168,23 +157,191 @@ fun UpdatesScreen( Spacer(modifier = Modifier.height(16.dp)) - // Check for updates button - Button( - onClick = { /* TODO: Implement update check */ }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF007AFF) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = "Check for Updates", - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) + // Action Button + when (val state = updateState) { + is UpdateState.Idle, is UpdateState.UpToDate, is UpdateState.Checking, is UpdateState.Error -> { + Button( + onClick = { + coroutineScope.launch { + UpdateManager.checkForUpdates() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = state !is UpdateState.Checking, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF007AFF) + ), + shape = RoundedCornerShape(12.dp) + ) { + if (state is UpdateState.Checking) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Checking...", fontSize = 16.sp, fontWeight = FontWeight.Medium) + } else { + Text("Check for Updates", fontSize = 16.sp, fontWeight = FontWeight.Medium) + } + } + } + + is UpdateState.UpdateAvailable -> { + Button( + onClick = { UpdateManager.downloadAndInstall(context) }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759) + ), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Download ${state.version}", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + is UpdateState.Downloading -> { + Column { + LinearProgressIndicator( + progress = state.progress / 100f, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + color = Color(0xFF007AFF), + trackColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0), + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "Downloading... ${state.progress}%", + fontSize = 14.sp, + color = textColor + ) + TextButton(onClick = { UpdateManager.cancelDownload() }) { + Text("Cancel", color = Color(0xFFFF3B30), fontSize = 14.sp) + } + } + } + } + + is UpdateState.ReadyToInstall -> { + Button( + onClick = { + UpdateManager.downloadAndInstall(context) + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759) + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + "Install Update", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } } } } } + +@Composable +private fun UpdateStatusCard( + state: UpdateState, + progress: Int, + secondaryTextColor: Color +) { + val (bgColor, iconColor, title, subtitle) = when (state) { + is UpdateState.Idle -> StatusCardData( + bg = Color(0xFF2E7D32).copy(alpha = 0.2f), + icon = Color(0xFF2E7D32), + title = "✓ App is up to date", + subtitle = "You're using the latest version" + ) + is UpdateState.UpToDate -> StatusCardData( + bg = Color(0xFF2E7D32).copy(alpha = 0.2f), + icon = Color(0xFF2E7D32), + title = "✓ App is up to date", + subtitle = "You're using the latest version" + ) + is UpdateState.Checking -> StatusCardData( + bg = Color(0xFF007AFF).copy(alpha = 0.2f), + icon = Color(0xFF007AFF), + title = "Checking for updates...", + subtitle = "Connecting to update server" + ) + is UpdateState.UpdateAvailable -> StatusCardData( + bg = Color(0xFF007AFF).copy(alpha = 0.2f), + icon = Color(0xFF007AFF), + title = "Update available: ${state.version}", + subtitle = "A new version is ready to download" + ) + is UpdateState.Downloading -> StatusCardData( + bg = Color(0xFF007AFF).copy(alpha = 0.2f), + icon = Color(0xFF007AFF), + title = "Downloading... $progress%", + subtitle = "Please wait while the update is downloading" + ) + is UpdateState.ReadyToInstall -> StatusCardData( + bg = Color(0xFF34C759).copy(alpha = 0.2f), + icon = Color(0xFF34C759), + title = "Ready to install", + subtitle = "Tap the button below to install the update" + ) + is UpdateState.Error -> StatusCardData( + bg = Color(0xFFFF3B30).copy(alpha = 0.2f), + icon = Color(0xFFFF3B30), + title = "Update check failed", + subtitle = state.message + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = bgColor, + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = iconColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + fontSize = 12.sp, + color = secondaryTextColor + ) + } + } +} + +private data class StatusCardData( + val bg: Color, + val icon: Color, + val title: String, + val subtitle: String +) diff --git a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt new file mode 100644 index 0000000..4b10c83 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt @@ -0,0 +1,424 @@ +package com.rosetta.messenger.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.content.FileProvider +import com.rosetta.messenger.BuildConfig +import com.rosetta.messenger.network.PacketRequestUpdate +import com.rosetta.messenger.network.ProtocolManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Менеджер обновлений приложения через SDU (Software Distribution Unit). + * + * Аналог UpdateProvider из desktop-приложения. + * Поток: + * 1. После аутентификации отправляем PacketRequestUpdate (0x0A) для получения URL SDU сервера + * 2. Запрашиваем /updates/get?kernel={version}&platform=android&arch={arch} на SDU + * 3. Если есть обновление — показываем пользователю + * 4. Скачиваем APK и открываем установщик + */ +object UpdateManager { + private const val TAG = "UpdateManager" + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // ═══ Debug log buffer ═══ + private val _debugLogs = MutableStateFlow>(emptyList()) + val debugLogs: StateFlow> = _debugLogs.asStateFlow() + private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + private fun sduLog(msg: String) { + Log.i(TAG, msg) + val ts = timeFmt.format(Date()) + val line = "[$ts] $msg" + _debugLogs.value = (_debugLogs.value + line).takeLast(200) + } + + // SDU server URL, получаем от основного сервера через PacketRequestUpdate + @Volatile + private var sduServerUrl: String? = null + + // Текущая версия приложения + val appVersion: String = BuildConfig.VERSION_NAME + + // Состояния обновления + private val _updateState = MutableStateFlow(UpdateState.Idle) + val updateState: StateFlow = _updateState.asStateFlow() + + private val _downloadProgress = MutableStateFlow(0) + val downloadProgress: StateFlow = _downloadProgress.asStateFlow() + + // Информация о доступном обновлении + @Volatile + private var latestUpdateInfo: UpdateInfo? = null + + private var downloadJob: Job? = null + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + /** + * Инициализация: регистрируем обработчик пакета 0x0A и запрашиваем SDU сервер + */ + fun init() { + sduLog("init() called, appVersion=$appVersion") + sduLog("Registering waitPacket(0x0A) listener...") + ProtocolManager.waitPacket(0x0A) { packet -> + sduLog("Received packet 0x0A, type=${packet::class.simpleName}") + if (packet is PacketRequestUpdate) { + val server = packet.updateServer + sduLog("PacketRequestUpdate.updateServer='$server'") + if (server.isNotEmpty()) { + sduServerUrl = server + sduLog("SDU server set: $server") + scope.launch { + sduLog("Auto-checking for updates...") + checkForUpdates() + } + } else { + sduLog("WARNING: updateServer is empty!") + } + } else { + sduLog("WARNING: packet is not PacketRequestUpdate, got ${packet::class.simpleName}") + } + } + sduLog("init() complete") + } + + /** + * Запросить URL SDU сервера у основного сервера + */ + fun requestSduServer() { + sduLog("requestSduServer() — sending PacketRequestUpdate with empty updateServer") + val packet = PacketRequestUpdate().apply { + updateServer = "" + } + ProtocolManager.sendPacket(packet) + sduLog("PacketRequestUpdate sent") + } + + /** + * Проверить наличие обновлений на SDU сервере + */ + suspend fun checkForUpdates(): UpdateInfo? = withContext(Dispatchers.IO) { + sduLog("checkForUpdates() called") + val sdu = sduServerUrl + if (sdu == null) { + sduLog("ERROR: sduServerUrl is null! Requesting again...") + requestSduServer() + return@withContext null + } + + _updateState.value = UpdateState.Checking + sduLog("State -> Checking") + + try { + val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a" + val url = "$sdu/updates/all?app=$appVersion&kernel=$appVersion&platform=android&arch=$arch" + sduLog("HTTP GET: $url") + + val request = Request.Builder().url(url).get().build() + val response = httpClient.newCall(request).execute() + + sduLog("HTTP response: ${response.code} ${response.message}") + + if (!response.isSuccessful) { + sduLog("ERROR: SDU returned ${response.code}") + _updateState.value = UpdateState.Error("Server returned ${response.code}") + return@withContext null + } + + val body = response.body?.string() ?: run { + sduLog("ERROR: Empty response body") + _updateState.value = UpdateState.Error("Empty response") + return@withContext null + } + + sduLog("Response body: $body") + val info = parseUpdateResponse(body) + latestUpdateInfo = info + sduLog("Parsed: version=${info.version}, servicePackUrl=${info.servicePackUrl}, kernelUpdateRequired=${info.kernelUpdateRequired}") + + when { + info.servicePackUrl != null -> { + sduLog("State -> UpdateAvailable(${info.version})") + _updateState.value = UpdateState.UpdateAvailable(info.version) + } + else -> { + sduLog("State -> UpToDate") + _updateState.value = UpdateState.UpToDate + } + } + + return@withContext info + } catch (e: Exception) { + sduLog("EXCEPTION: ${e::class.simpleName}: ${e.message}") + _updateState.value = UpdateState.Error(e.message ?: "Unknown error") + return@withContext null + } + } + + /** + * Скачать и установить обновление + */ + fun downloadAndInstall(context: Context) { + val info = latestUpdateInfo ?: return + val sdu = sduServerUrl ?: return + val url = info.servicePackUrl ?: return + + downloadJob?.cancel() + downloadJob = scope.launch { + _updateState.value = UpdateState.Downloading(0) + _downloadProgress.value = 0 + + try { + val fullUrl = if (url.startsWith("http")) url else "$sdu$url" + Log.i(TAG, "Downloading update from: $fullUrl") + + val request = Request.Builder().url(fullUrl).get().build() + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + _updateState.value = UpdateState.Error("Download failed: ${response.code}") + return@launch + } + + val responseBody = response.body ?: run { + _updateState.value = UpdateState.Error("Empty download response") + return@launch + } + + val totalBytes = responseBody.contentLength() + val apkFile = File(context.cacheDir, "Rosetta-${info.version}.apk") + + responseBody.byteStream().use { input -> + apkFile.outputStream().use { output -> + val buffer = ByteArray(8192) + var bytesRead: Long = 0 + var read: Int + + while (input.read(buffer).also { read = it } != -1) { + if (!isActive) { + apkFile.delete() + return@launch + } + output.write(buffer, 0, read) + bytesRead += read + if (totalBytes > 0) { + val progress = ((bytesRead * 100) / totalBytes).toInt() + _downloadProgress.value = progress + _updateState.value = UpdateState.Downloading(progress) + } + } + } + } + + Log.i(TAG, "Download complete: ${apkFile.absolutePath}") + _updateState.value = UpdateState.ReadyToInstall(apkFile.absolutePath) + + // Запускаем установку APK + installApk(context, apkFile) + + } catch (e: CancellationException) { + Log.i(TAG, "Download cancelled") + _updateState.value = UpdateState.UpdateAvailable(info.version) + } catch (e: Exception) { + Log.e(TAG, "Download failed", e) + _updateState.value = UpdateState.Error("Download failed: ${e.message}") + } + } + } + + /** + * Открыть системный установщик APK + */ + private fun installApk(context: Context, apkFile: File) { + try { + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + apkFile + ) + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to launch installer", e) + _updateState.value = UpdateState.Error("Failed to open installer: ${e.message}") + } + } + + /** + * Отменить текущее скачивание + */ + fun cancelDownload() { + downloadJob?.cancel() + downloadJob = null + val info = latestUpdateInfo + _updateState.value = if (info?.servicePackUrl != null) + UpdateState.UpdateAvailable(info.version) else UpdateState.Idle + _downloadProgress.value = 0 + } + + /** + * Сбросить состояние + */ + fun reset() { + _updateState.value = UpdateState.Idle + _downloadProgress.value = 0 + latestUpdateInfo = null + } + + /** + * Парсинг JSON ответа от SDU /updates/all + * Формат: {"items":[{"platform":"android","arch":"x64","version":"1.0.7","downloadUrl":"/kernel/android/x64/Rosetta-1.0.7.apk"}, ...]} + * Ищем запись с platform=android, берём самую новую версию + */ + private fun parseUpdateResponse(json: String): UpdateInfo { + val obj = org.json.JSONObject(json) + val items = obj.optJSONArray("items") + + if (items == null || items.length() == 0) { + sduLog("No items in response, trying legacy format...") + // Fallback на старый формат /updates/get + return UpdateInfo( + version = obj.optString("version", ""), + platform = obj.optString("platform", ""), + arch = obj.optString("arch", ""), + kernelUpdateRequired = obj.optBoolean("kernel_update_required", false), + servicePackUrl = obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" }, + kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" } + ) + } + + // Ищем все android-записи + var bestVersion: String? = null + var bestDownloadUrl: String? = null + var bestArch: String? = null + + val deviceArch = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a" + // Маппинг ABI -> SDU arch + val sduArch = when { + deviceArch.contains("arm64") -> "arm64" + deviceArch.contains("arm") -> "arm" + deviceArch.contains("x86_64") -> "x64" + deviceArch.contains("x86") -> "x86" + else -> deviceArch + } + + sduLog("Looking for platform=android, preferring arch=$sduArch (device ABI=$deviceArch)") + + for (i in 0 until items.length()) { + val item = items.getJSONObject(i) + val platform = item.optString("platform", "") + if (platform != "android") continue + + val itemArch = item.optString("arch", "") + val itemVersion = item.optString("version", "") + val itemUrl = item.optString("downloadUrl", "") + + sduLog("Found android entry: arch=$itemArch, version=$itemVersion, url=$itemUrl") + + // Берём самую новую версию (предпочитаем совпадение по arch) + if (bestVersion == null || compareVersions(itemVersion, bestVersion) > 0 || + (compareVersions(itemVersion, bestVersion) == 0 && itemArch == sduArch)) { + bestVersion = itemVersion + bestDownloadUrl = itemUrl + bestArch = itemArch + } + } + + if (bestVersion == null) { + sduLog("No android entries found in items") + return UpdateInfo("", "android", "", false, null, null) + } + + sduLog("Best android update: version=$bestVersion, arch=$bestArch, url=$bestDownloadUrl") + + // Сравниваем с текущей версией + val isNewer = compareVersions(bestVersion, appVersion) > 0 + sduLog("Current=$appVersion, available=$bestVersion, isNewer=$isNewer") + + return UpdateInfo( + version = bestVersion, + platform = "android", + arch = bestArch ?: "", + kernelUpdateRequired = false, + servicePackUrl = if (isNewer) bestDownloadUrl else null, + kernelUrl = null + ) + } + + /** + * Сравнение версий: "1.0.7" vs "1.0.6" -> 1 (первая больше) + */ + private fun compareVersions(v1: String, v2: String): Int { + val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 } + val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 } + val maxLen = maxOf(parts1.size, parts2.size) + for (i in 0 until maxLen) { + val p1 = parts1.getOrElse(i) { 0 } + val p2 = parts2.getOrElse(i) { 0 } + if (p1 != p2) return p1.compareTo(p2) + } + return 0 + } +} + +/** + * Информация об обновлении от SDU сервера + */ +data class UpdateInfo( + val version: String, + val platform: String, + val arch: String, + val kernelUpdateRequired: Boolean, + val servicePackUrl: String?, + val kernelUrl: String? +) + +/** + * Состояния процесса обновления + */ +sealed class UpdateState { + /** Начальное состояние */ + data object Idle : UpdateState() + + /** Проверяем наличие обновлений */ + data object Checking : UpdateState() + + /** Приложение обновлено */ + data object UpToDate : UpdateState() + + /** Доступно обновление */ + data class UpdateAvailable(val version: String) : UpdateState() + + /** Скачивание APK */ + data class Downloading(val progress: Int) : UpdateState() + + /** Готово к установке */ + data class ReadyToInstall(val apkPath: String) : UpdateState() + + /** Ошибка */ + data class Error(val message: String) : UpdateState() +} diff --git a/app/src/main/res/drawable/ic_rosette_check.xml b/app/src/main/res/drawable/ic_rosette_check.xml new file mode 100644 index 0000000..9bad729 --- /dev/null +++ b/app/src/main/res/drawable/ic_rosette_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_rosette_discount_check.xml b/app/src/main/res/drawable/ic_rosette_discount_check.xml new file mode 100644 index 0000000..8534e2e --- /dev/null +++ b/app/src/main/res/drawable/ic_rosette_discount_check.xml @@ -0,0 +1,11 @@ + + + +