feat: Update ChatDetailScreen and ChatsListScreen for improved UI responsiveness and consistency; add custom verified badge icon

This commit is contained in:
2026-02-24 20:35:33 +05:00
parent 73ec5d77f4
commit 6643e0e069
17 changed files with 931 additions and 153 deletions

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger
import android.app.Application import android.app.Application
import com.airbnb.lottie.L import com.airbnb.lottie.L
import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
/** /**
@@ -26,6 +27,9 @@ class RosettaApplication : Application() {
// Инициализируем менеджер черновиков // Инициализируем менеджер черновиков
DraftManager.init(this) DraftManager.init(this)
// Инициализируем менеджер обновлений (SDU)
UpdateManager.init()
} }
/** /**

View File

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

View File

@@ -124,6 +124,7 @@ class Protocol(
0x07 to { PacketRead() }, 0x07 to { PacketRead() },
0x08 to { PacketDelivery() }, 0x08 to { PacketDelivery() },
0x09 to { PacketDeviceNew() }, 0x09 to { PacketDeviceNew() },
0x0A to { PacketRequestUpdate() },
0x0B to { PacketTyping() }, 0x0B to { PacketTyping() },
0x0F to { PacketRequestTransport() }, 0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() }, 0x17 to { PacketDeviceList() },

View File

@@ -369,6 +369,7 @@ object ProtocolManager {
private fun onAuthenticated() { private fun onAuthenticated() {
setSyncInProgress(false) setSyncInProgress(false)
TransportManager.requestTransportServer() TransportManager.requestTransportServer()
com.rosetta.messenger.update.UpdateManager.requestSduServer()
fetchOwnProfile() fetchOwnProfile()
requestSynchronize() requestSynchronize()
subscribePushTokenIfAvailable() subscribePushTokenIfAvailable()

View File

@@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -142,7 +143,7 @@ fun ChatDetailScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) 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 // 🔥 Keyboard & Emoji Coordinator
val coordinator = remember { KeyboardTransitionCoordinator() } val coordinator = remember { KeyboardTransitionCoordinator() }
@@ -232,7 +233,7 @@ fun ChatDetailScreen(
if (window != null && view != null) { if (window != null && view != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = !isDarkTheme ic.isAppearanceLightStatusBars = false
} }
} }
} }
@@ -659,7 +660,7 @@ fun ChatDetailScreen(
// 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer) // 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer)
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Telegram-style solid header background (без blur) // 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( Scaffold(
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
@@ -669,13 +670,7 @@ fun ChatDetailScreen(
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background( .background(headerBackground)
if (isSelectionMode) {
if (isDarkTheme)
Color(0xFF212121)
else Color.White
} else headerBackground
)
// 🎨 statusBarsPadding ПОСЛЕ background = // 🎨 statusBarsPadding ПОСЛЕ background =
// хедер начинается под статус баром // хедер начинается под статус баром
.statusBarsPadding() .statusBarsPadding()
@@ -737,12 +732,7 @@ fun ChatDetailScreen(
fontWeight = fontWeight =
FontWeight FontWeight
.Bold, .Bold,
color = color = Color.White
if (isDarkTheme
)
Color.White
else
Color.Black
) )
} }
@@ -880,14 +870,7 @@ fun ChatDetailScreen(
imageVector = TablerIcons.ChevronLeft, imageVector = TablerIcons.ChevronLeft,
contentDescription = contentDescription =
"Back", "Back",
tint = tint = Color.White,
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
),
modifier = modifier =
Modifier.size( Modifier.size(
28.dp 28.dp
@@ -1007,15 +990,14 @@ fun ChatDetailScreen(
CircleShape CircleShape
) )
.background( .background(
PrimaryBlue Color(0xFF228BE6)
), ),
contentAlignment = contentAlignment =
Alignment Alignment
.Center .Center
) { ) {
Icon( Icon(
Icons.Default painter = painterResource(R.drawable.bookmark_outlined),
.Bookmark,
contentDescription = contentDescription =
null, null,
tint = tint =
@@ -1102,7 +1084,7 @@ fun ChatDetailScreen(
FontWeight FontWeight
.SemiBold, .SemiBold,
color = color =
textColor, Color.White,
maxLines = maxLines =
1, 1,
overflow = overflow =
@@ -1125,7 +1107,8 @@ fun ChatDetailScreen(
size = size =
16, 16,
isDarkTheme = isDarkTheme =
isDarkTheme isDarkTheme,
badgeTint = if (isDarkTheme) null else Color(0xFFACD2F9)
) )
} }
// 🔇 Mute icon // 🔇 Mute icon
@@ -1135,7 +1118,7 @@ fun ChatDetailScreen(
painter = TelegramIcons.Mute, painter = TelegramIcons.Mute,
contentDescription = "Muted", contentDescription = "Muted",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = secondaryTextColor tint = Color.White.copy(alpha = 0.7f)
) )
} }
} }
@@ -1155,13 +1138,13 @@ fun ChatDetailScreen(
color = color =
when { when {
isSavedMessages -> isSavedMessages ->
secondaryTextColor Color.White.copy(alpha = 0.7f)
isOnline -> isOnline ->
Color( Color(
0xFF38B24D 0xFF7FE084
) // Зелёный когда онлайн ) // Зелёный когда онлайн (светлый на синем фоне)
else -> else ->
secondaryTextColor // Серый Color.White.copy(alpha = 0.7f) // Белый полупрозрачный
// для // для
// offline // offline
}, },
@@ -1182,14 +1165,7 @@ fun ChatDetailScreen(
.Call, .Call,
contentDescription = contentDescription =
"Call", "Call",
tint = tint = Color.White
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
)
) )
} }
} }
@@ -1221,14 +1197,7 @@ fun ChatDetailScreen(
.MoreVert, .MoreVert,
contentDescription = contentDescription =
"More", "More",
tint = tint = Color.White,
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
),
modifier = modifier =
Modifier.size( Modifier.size(
26.dp 26.dp
@@ -1287,15 +1256,7 @@ fun ChatDetailScreen(
.fillMaxWidth() .fillMaxWidth()
.height(0.5.dp) .height(0.5.dp)
.background( .background(
if (isDarkTheme) Color.White.copy(alpha = 0.15f)
Color.White.copy(
alpha =
0.15f
)
else
Color.Black.copy(
alpha = 0.1f
)
) )
) )
} // Закрытие Box unified header } // Закрытие Box unified header

View File

@@ -73,6 +73,8 @@ import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
@@ -245,6 +247,10 @@ fun ChatsListScreen(
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() 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) } val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) } var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) } var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
@@ -253,6 +259,73 @@ fun ChatsListScreen(
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) } var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(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() { fun startThemeReveal() {
if (themeRevealActive) { if (themeRevealActive) {
return return
@@ -801,7 +874,7 @@ fun ChatsListScreen(
VerifiedBadge( VerifiedBadge(
verified = 1, verified = 1,
size = 15, 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( Divider(
color = color =
if (isDarkTheme) if (isDarkTheme)
@@ -1089,29 +1168,137 @@ fun ChatsListScreen(
thickness = 0.5.dp thickness = 0.5.dp
) )
// Version info // Version info — прячем когда есть баннер обновления
Box( if (!showUpdateBanner) {
modifier = Row(
Modifier.fillMaxWidth() modifier =
.padding( Modifier.fillMaxWidth()
horizontal = 20.dp, .padding(
vertical = 12.dp horizontal = 20.dp,
), vertical = 12.dp
contentAlignment = ),
Alignment.CenterStart verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Rosetta v${BuildConfig.VERSION_NAME}", text = "Rosetta v${BuildConfig.VERSION_NAME}",
fontSize = 12.sp, fontSize = 12.sp,
color = color =
if (isDarkTheme) if (isDarkTheme)
Color(0xFF666666) Color(0xFF666666)
else else
Color(0xFF999999) 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, state = chatListState,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.nestedScroll(requestsNestedScroll) .then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
else Modifier
)
.background( .background(
listBackgroundColor listBackgroundColor
) )
@@ -3384,7 +3574,7 @@ fun DialogItemContent(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue), .background(Color(0xFF228BE6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -347,7 +347,7 @@ private fun ForwardDialogItem(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue), .background(Color(0xFF228BE6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -18,11 +18,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieComposition
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -182,12 +184,12 @@ private fun SearchResultItem(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor), .background(if (isOwnAccount) Color(0xFF228BE6) else avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isOwnAccount) { if (isOwnAccount) {
Icon( Icon(
Icons.Default.Bookmark, painter = painterResource(R.drawable.bookmark_outlined),
contentDescription = "Saved Messages", contentDescription = "Saved Messages",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -438,11 +439,9 @@ private fun RecentUserItem(
) )
if (user.verified != 0) { if (user.verified != 0) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Icon( VerifiedBadge(
Icons.Default.Verified, verified = user.verified,
contentDescription = "Verified", size = 16
tint = PrimaryBlue,
modifier = Modifier.size(16.dp)
) )
} }
} }

View File

@@ -4,16 +4,17 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog 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) } var showDialog by remember { mutableStateOf(false) }
// Цвет в зависимости от уровня верификации // Цвет верификации: в тёмной теме — как индикаторы прочтения (PrimaryBlue), в светлой — #ACD2F9
val badgeColor = val badgeColor =
badgeTint badgeTint ?: if (isDarkTheme) PrimaryBlue else Color(0xFFACD2F9)
?: when (verified) {
1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram)
2 -> Color(0xFFFFD700) // Золотая верификация
else -> Color(0xFF4CAF50) // Зеленая для других уровней
}
// Текст аннотации // Текст аннотации
val annotationText = when (verified) { val annotationText = when (verified) {
@@ -52,7 +48,7 @@ fun VerifiedBadge(
} }
Icon( Icon(
Icons.Default.Verified, painter = painterResource(id = R.drawable.ic_rosette_discount_check),
contentDescription = "Verified", contentDescription = "Verified",
tint = badgeColor, tint = badgeColor,
modifier = modifier modifier = modifier
@@ -73,7 +69,7 @@ fun VerifiedBadge(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( Icon(
Icons.Default.Verified, painter = painterResource(id = R.drawable.ic_rosette_discount_check),
contentDescription = null, contentDescription = null,
tint = badgeColor, tint = badgeColor,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)

View File

@@ -1935,8 +1935,7 @@ private fun CollapsingOtherProfileHeader(
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 1, verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(), size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme
badgeTint = Color.White
) )
} }
} }

View File

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

View File

@@ -7,20 +7,21 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.update.UpdateState
import kotlinx.coroutines.launch
@Composable @Composable
fun UpdatesScreen( fun UpdatesScreen(
@@ -28,6 +29,8 @@ fun UpdatesScreen(
onBack: () -> Unit onBack: () -> Unit
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
@@ -44,7 +47,10 @@ fun UpdatesScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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() } BackHandler { onBack() }
Column( Column(
@@ -87,29 +93,12 @@ fun UpdatesScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// Info Card // Status Card
Surface( UpdateStatusCard(
modifier = Modifier.fillMaxWidth(), state = updateState,
color = Color(0xFF2E7D32).copy(alpha = 0.2f), progress = downloadProgress,
shape = RoundedCornerShape(12.dp) secondaryTextColor = secondaryTextColor
) { )
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
)
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -168,23 +157,191 @@ fun UpdatesScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Check for updates button // Action Button
Button( when (val state = updateState) {
onClick = { /* TODO: Implement update check */ }, is UpdateState.Idle, is UpdateState.UpToDate, is UpdateState.Checking, is UpdateState.Error -> {
modifier = Modifier Button(
.fillMaxWidth() onClick = {
.height(48.dp), coroutineScope.launch {
colors = ButtonDefaults.buttonColors( UpdateManager.checkForUpdates()
containerColor = Color(0xFF007AFF) }
), },
shape = RoundedCornerShape(12.dp) modifier = Modifier
) { .fillMaxWidth()
Text( .height(48.dp),
text = "Check for Updates", enabled = state !is UpdateState.Checking,
fontSize = 16.sp, colors = ButtonDefaults.buttonColors(
fontWeight = FontWeight.Medium 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
)

View File

@@ -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<List<String>>(emptyList())
val debugLogs: StateFlow<List<String>> = _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>(UpdateState.Idle)
val updateState: StateFlow<UpdateState> = _updateState.asStateFlow()
private val _downloadProgress = MutableStateFlow(0)
val downloadProgress: StateFlow<Int> = _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()
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Checkmark centered inside rosette -->
<path
android:fillColor="#FF000000"
android:pathData="M16.14,9.34a1,1,0,0,0,-1.414,0l-3.876,3.876l-1.586,-1.586a1,1,0,0,0,-1.414,1.414l2.293,2.293a1,1,0,0,0,1.414,0l4.583,-4.583a1,1,0,0,0,0,-1.414z" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Rosette with checkmark cutout (evenOdd) -->
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M12.01,2.011a3.2,3.2,0,0,1,2.113,0.797l0.154,0.145l0.698,0.698a1.2,1.2,0,0,0,0.71,0.341l0.135,0.008h1a3.2,3.2,0,0,1,3.195,3.018l0.005,0.182v1c0,0.27,0.092,0.533,0.258,0.743l0.09,0.1l0.697,0.698a3.2,3.2,0,0,1,0.147,4.382l-0.145,0.154l-0.698,0.698a1.2,1.2,0,0,0,-0.341,0.71l-0.008,0.135v1a3.2,3.2,0,0,1,-3.018,3.195l-0.182,0.005h-1a1.2,1.2,0,0,0,-0.743,0.258l-0.1,0.09l-0.698,0.697a3.2,3.2,0,0,1,-4.382,0.147l-0.154,-0.145l-0.698,-0.698a1.2,1.2,0,0,0,-0.71,-0.341l-0.135,-0.008h-1a3.2,3.2,0,0,1,-3.195,-3.018l-0.005,-0.182v-1a1.2,1.2,0,0,0,-0.258,-0.743l-0.09,-0.1l-0.697,-0.698a3.2,3.2,0,0,1,-0.147,-4.382l0.145,-0.154l0.698,-0.698a1.2,1.2,0,0,0,0.341,-0.71l0.008,-0.135v-1l0.005,-0.182a3.2,3.2,0,0,1,3.013,-3.013l0.182,-0.005h1a1.2,1.2,0,0,0,0.743,-0.258l0.1,-0.09l0.698,-0.697a3.2,3.2,0,0,1,2.123,-0.944l0.187,-0.003z M16.14,9.34a1,1,0,0,0,-1.414,0l-3.876,3.876l-1.586,-1.586a1,1,0,0,0,-1.414,1.414l2.293,2.293a1,1,0,0,0,1.414,0l4.583,-4.583a1,1,0,0,0,0,-1.414z" />
</vector>