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