feat: Update ChatDetailScreen and ChatsListScreen for improved UI responsiveness and consistency; add custom verified badge icon
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 +1168,16 @@ fun ChatsListScreen(
|
|||||||
thickness = 0.5.dp
|
thickness = 0.5.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version info
|
// Version info — прячем когда есть баннер обновления
|
||||||
Box(
|
if (!showUpdateBanner) {
|
||||||
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = 20.dp,
|
horizontal = 20.dp,
|
||||||
vertical = 12.dp
|
vertical = 12.dp
|
||||||
),
|
),
|
||||||
contentAlignment =
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Rosetta v${BuildConfig.VERSION_NAME}",
|
text = "Rosetta v${BuildConfig.VERSION_NAME}",
|
||||||
@@ -1109,9 +1188,117 @@ fun ChatsListScreen(
|
|||||||
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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +157,105 @@ fun UpdatesScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Check for updates button
|
// Action Button
|
||||||
|
when (val state = updateState) {
|
||||||
|
is UpdateState.Idle, is UpdateState.UpToDate, is UpdateState.Checking, is UpdateState.Error -> {
|
||||||
Button(
|
Button(
|
||||||
onClick = { /* TODO: Implement update check */ },
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
UpdateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
|
enabled = state !is UpdateState.Checking,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFF007AFF)
|
containerColor = Color(0xFF007AFF)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
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(
|
Text(
|
||||||
text = "Check for Updates",
|
"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,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
@@ -188,3 +263,85 @@ fun UpdatesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|||||||
424
app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt
Normal file
424
app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt
Normal 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()
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_rosette_check.xml
Normal file
10
app/src/main/res/drawable/ic_rosette_check.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_rosette_discount_check.xml
Normal file
11
app/src/main/res/drawable/ic_rosette_discount_check.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user