Switch MAX bridge to PyMax only

This commit is contained in:
sevenhill
2026-07-07 14:18:49 +03:00
parent 8efeff334a
commit af84343e19
19 changed files with 314 additions and 8088 deletions
@@ -164,6 +164,7 @@ import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState
@@ -296,7 +297,7 @@ private fun LoginScreen(vm: QMaxViewModel) {
verticalArrangement = Arrangement.Center
) {
Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue)
Text("MAX Web в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Spacer(Modifier.height(28.dp))
OutlinedTextField(
value = state.serverUrl,
@@ -370,6 +371,10 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats,
onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
onStartMaxLogin = vm::startMaxLogin,
onMaxCode = vm::updateMaxCode,
onSubmitMaxCode = vm::submitMaxCode,
onLogout = vm::logout,
onSearch = vm::updateSearchQuery,
onTabSelected = { tab ->
@@ -596,6 +601,10 @@ private fun TelegramChatListContent(
onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit,
onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onLogout: () -> Unit,
onSearch: (String) -> Unit,
onTabSelected: (MainTab) -> Unit,
@@ -648,10 +657,17 @@ private fun TelegramChatListContent(
)
}
ErrorLine(state.error)
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
}
if (activeTab == MainTab.Settings) {
SettingsTabContent(
state = state,
onCheckUpdates = onCheckUpdates,
onRefreshMaxStatus = onRefreshMaxStatus,
onStartMaxLogin = onStartMaxLogin,
onMaxCode = onMaxCode,
onSubmitMaxCode = onSubmitMaxCode,
onRefresh = onRefresh,
onLogout = onLogout
)
@@ -1039,10 +1055,29 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: ()
}
}
@Composable
private fun MaxSessionExpiredBanner(onClick: () -> Unit) {
Surface(
color = Color(0xFFFFF3E0),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) {
Text("Сессия MAX закончилась", fontWeight = FontWeight.SemiBold, color = Color(0xFF9A4D00))
Text("Откройте настройки, получите новый код и отдайте его QMAX.", color = QMaxMuted, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun SettingsTabContent(
state: QMaxUiState,
onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit
) {
@@ -1066,19 +1101,67 @@ private fun SettingsTabContent(
HorizontalDivider(color = Color(0xFFE7EEF3))
}
item {
val status = state.maxStatus
val loginAvailable = status?.isAuthorized == false
val waitingForCode = isMaxLoginWaitingForCode(status)
val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading
val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading
Spacer(Modifier.height(22.dp))
Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(
state.maxStatus?.status ?: "Статус неизвестен",
maxStatusTitle(status),
style = MaterialTheme.typography.bodyLarge,
color = QMaxMuted
color = if (loginAvailable) Color(0xFFC62828) else QMaxMuted
)
state.maxStatus?.lastError?.takeIf { it.isNotBlank() }?.let {
Text(
maxStatusHint(status),
style = MaterialTheme.typography.bodyMedium,
color = QMaxMuted,
modifier = Modifier.padding(top = 4.dp)
)
status?.lastError?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828))
}
Spacer(Modifier.height(12.dp))
Column {
Button(
onClick = onRefreshMaxStatus,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth()
) {
Text("Обновить статус")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = onStartMaxLogin,
enabled = requestCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text(if (waitingForCode) "Код запрошен" else "Получить новый код")
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = onMaxCode,
enabled = loginAvailable && !state.loading,
label = { Text("Новый код MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmitMaxCode,
enabled = submitCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Отдать код")
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
Spacer(Modifier.height(22.dp))
Button(onClick = onRefresh, enabled = !state.loading) {
Text("Обновить чаты")
}
@@ -1090,6 +1173,31 @@ private fun SettingsTabContent(
}
}
private fun isMaxLoginWaitingForCode(status: MaxBridgeStatusDto?): Boolean {
return status?.loginStage.equals("Code", ignoreCase = true) ||
status?.status.equals("Code", ignoreCase = true)
}
private fun maxStatusTitle(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Статус неизвестен"
status.isAuthorized -> "Сессия MAX активна"
status.loginStage.equals("Expired", ignoreCase = true) ||
status.status.equals("SessionExpired", ignoreCase = true) -> "Сессия MAX закончилась"
isMaxLoginWaitingForCode(status) -> "MAX ждёт код подтверждения"
else -> "MAX требует вход"
}
}
private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Проверка статуса выполняется автоматически. Можно обновить статус вручную."
status.isAuthorized -> "Получение нового кода будет доступно только после окончания сессии."
isMaxLoginWaitingForCode(status) -> "Введите код из MAX и нажмите «Отдать код»."
else -> "Нажмите «Получить новый код», затем введите код подтверждения."
}
}
@Composable
private fun TelegramBottomNavigation(
modifier: Modifier = Modifier,
@@ -8,6 +8,7 @@ import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
@@ -26,7 +27,8 @@ class QMaxRealtimeClient {
onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit
onMessageDeleted: (MessageDeletedDto) -> Unit,
onMaxStatusChanged: (MaxBridgeStatusDto) -> Unit
) = withContext(Dispatchers.IO) {
disconnect()
@@ -36,6 +38,15 @@ class QMaxRealtimeClient {
.build()
connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MaxStatusChanged",
{ payload: JsonElement ->
runCatching {
onMaxStatusChanged(json.decodeFromString<MaxBridgeStatusDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on(
"MessageCreated",
{ payload: JsonElement ->
@@ -72,6 +72,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null
private var maxStatusPollJob: Job? = null
private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null
private var pushRegistrationJob: Job? = null
@@ -107,11 +108,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats()
connectRealtime(session)
registerPushDevice(session)
loadMaxStatus()
startMaxStatusPolling(session)
} else {
realtimeConnectJob?.cancel()
messagePollJob?.cancel()
presencePollJob?.cancel()
maxStatusPollJob?.cancel()
pushRegistrationJob?.cancel()
pushRegistrationInFlightForToken = null
pushRegisteredForToken = null
@@ -687,19 +689,20 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadMaxStatus() = launchLoading(false) {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session))
refreshMaxStatus(session)
}
fun startMaxLogin() = launchLoading {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null))
applyMaxStatus(repository.startMaxLogin(session, null))
}
fun submitMaxCode() = launchLoading {
val session = requireSession()
val code = state.value.maxCode.trim()
if (code.isBlank()) return@launchLoading
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "")
applyMaxStatus(repository.submitMaxCode(session, code))
state.value = state.value.copy(maxCode = "")
}
fun checkArgusUpdate() = launchLoading {
@@ -1044,6 +1047,28 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
}
private fun startMaxStatusPolling(session: QMaxSession) {
maxStatusPollJob?.cancel()
maxStatusPollJob = viewModelScope.launch {
while (true) {
runCatching {
refreshMaxStatus(session)
}.onFailure {
Log.w(LogTag, "MAX status refresh failed", it)
}
delay(MaxStatusPollIntervalMs)
}
}
}
private suspend fun refreshMaxStatus(session: QMaxSession) {
applyMaxStatus(repository.maxStatus(session))
}
private fun applyMaxStatus(status: MaxBridgeStatusDto) {
state.value = state.value.copy(maxStatus = status)
}
private fun connectRealtime(session: QMaxSession) {
realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch {
@@ -1053,7 +1078,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted
onMessageDeleted = ::handleRealtimeMessageDeleted,
onMaxStatusChanged = ::applyMaxStatus
)
realtime.joinChat(state.value.selectedChat?.id)
}
@@ -1245,6 +1271,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val MaxStatusPollIntervalMs = 60_000L
const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L