Релиз 1.4.7: фиксы lockscreen, звонков и стабильности
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.4.6"
|
||||
val rosettaVersionCode = 48 // Increment on each release
|
||||
val rosettaVersionName = "1.4.7"
|
||||
val rosettaVersionCode = 49 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -46,9 +46,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true">
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.rosetta.messenger
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -10,7 +8,6 @@ import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.*
|
||||
import com.rosetta.messenger.network.CallActionResult
|
||||
import com.rosetta.messenger.network.CallForegroundService
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
@@ -54,14 +51,8 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
// Dismiss keyguard
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
km?.requestDismissKeyguard(this, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
|
||||
}
|
||||
// Важно: не снимаем keyguard автоматически.
|
||||
// Экран звонка может отображаться поверх lockscreen, но разблокировку делает только пользователь.
|
||||
|
||||
try {
|
||||
CallManager.initialize(applicationContext)
|
||||
@@ -77,6 +68,7 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
|
||||
// Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE)
|
||||
var wasIncoming by remember { mutableStateOf(false) }
|
||||
var lastPeerIdentity by remember { mutableStateOf(Triple("", "", "")) }
|
||||
|
||||
LaunchedEffect(callState.phase) {
|
||||
callLog("phase changed: ${callState.phase}")
|
||||
@@ -90,6 +82,17 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
|
||||
}
|
||||
|
||||
LaunchedEffect(callState.peerPublicKey, callState.peerTitle, callState.peerUsername) {
|
||||
val hasIdentity =
|
||||
callState.peerPublicKey.isNotBlank() ||
|
||||
callState.peerTitle.isNotBlank() ||
|
||||
callState.peerUsername.isNotBlank()
|
||||
if (hasIdentity) {
|
||||
lastPeerIdentity =
|
||||
Triple(callState.peerPublicKey, callState.peerTitle, callState.peerUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
|
||||
// Иначе после Decline/END на мгновение мелькает "Unknown".
|
||||
val shouldShowProvisionalIncoming =
|
||||
@@ -101,6 +104,13 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
|
||||
val displayState = if (shouldShowProvisionalIncoming) {
|
||||
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
|
||||
} else if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||
// Во время закрытия Activity сохраняем последнее известное имя/peer, чтобы не мигал Unknown.
|
||||
callState.copy(
|
||||
peerPublicKey = lastPeerIdentity.first,
|
||||
peerTitle = lastPeerIdentity.second,
|
||||
peerUsername = lastPeerIdentity.third
|
||||
)
|
||||
} else {
|
||||
callState
|
||||
}
|
||||
|
||||
@@ -463,58 +463,47 @@ class MainActivity : FragmentActivity() {
|
||||
handleCallLockScreen(intent)
|
||||
}
|
||||
|
||||
private var callLockScreenJob: kotlinx.coroutines.Job? = null
|
||||
private var callIntentResetJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Показать Activity поверх экрана блокировки при входящем звонке.
|
||||
* При завершении звонка флаги снимаются чтобы не нарушать обычное поведение.
|
||||
* Обрабатывает переход из call-notification.
|
||||
* На lockscreen НЕ поднимаем MainActivity поверх keyguard и НЕ снимаем блокировку.
|
||||
*/
|
||||
private fun handleCallLockScreen(intent: Intent?) {
|
||||
val isCallIntent = intent?.getBooleanExtra(
|
||||
com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false
|
||||
) == true
|
||||
if (isCallIntent) {
|
||||
openedForCall = true
|
||||
// Включаем экран и показываем поверх lock screen
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
// Убираем lock screen полностью
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager
|
||||
keyguardManager?.requestDismissKeyguard(this, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
|
||||
}
|
||||
// Снять флаги когда звонок закончится (отменяем предыдущий коллектор если был)
|
||||
callLockScreenJob?.cancel()
|
||||
callLockScreenJob = lifecycleScope.launch {
|
||||
com.rosetta.messenger.network.CallManager.state.collect { state ->
|
||||
if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) {
|
||||
openedForCall = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.clearFlags(
|
||||
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager
|
||||
val isDeviceLocked = keyguardManager?.isDeviceLocked == true
|
||||
// Если экран заблокирован — не обходим auth и не показываем MainActivity поверх keyguard.
|
||||
openedForCall = !isDeviceLocked
|
||||
|
||||
if (openedForCall) {
|
||||
callIntentResetJob?.cancel()
|
||||
callIntentResetJob = lifecycleScope.launch {
|
||||
com.rosetta.messenger.network.CallManager.state.collect { state ->
|
||||
if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) {
|
||||
openedForCall = false
|
||||
callIntentResetJob?.cancel()
|
||||
callIntentResetJob = null
|
||||
}
|
||||
callLockScreenJob?.cancel()
|
||||
callLockScreenJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// На всякий случай принудительно чистим lock-screen флаги.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.clearFlags(
|
||||
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class AccountManager(private val context: Context) {
|
||||
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||
private const val PREFS_NAME = "rosetta_account_prefs"
|
||||
private const val KEY_LAST_LOGGED = "last_logged_public_key"
|
||||
private const val KEY_LAST_LOGGED_PRIVATE_HASH = "last_logged_private_hash"
|
||||
}
|
||||
|
||||
// Use SharedPreferences for last logged account - more reliable for immediate reads
|
||||
@@ -44,12 +45,18 @@ class AccountManager(private val context: Context) {
|
||||
return publicKey
|
||||
}
|
||||
|
||||
fun getLastLoggedPrivateKeyHash(): String? {
|
||||
return sharedPrefs.getString(KEY_LAST_LOGGED_PRIVATE_HASH, null)
|
||||
}
|
||||
|
||||
// Synchronous write to SharedPreferences
|
||||
fun setLastLoggedPublicKey(publicKey: String) {
|
||||
val success = sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
|
||||
sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
|
||||
}
|
||||
|
||||
// Verify immediately
|
||||
val saved = sharedPrefs.getString(KEY_LAST_LOGGED, null)
|
||||
fun setLastLoggedPrivateKeyHash(privateKeyHash: String) {
|
||||
if (privateKeyHash.isBlank()) return
|
||||
sharedPrefs.edit().putString(KEY_LAST_LOGGED_PRIVATE_HASH, privateKeyHash).apply()
|
||||
}
|
||||
|
||||
suspend fun saveAccount(account: EncryptedAccount) {
|
||||
@@ -98,6 +105,7 @@ class AccountManager(private val context: Context) {
|
||||
context.accountDataStore.edit { preferences ->
|
||||
preferences[IS_LOGGED_IN] = false
|
||||
}
|
||||
sharedPrefs.edit().remove(KEY_LAST_LOGGED_PRIVATE_HASH).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,12 +148,21 @@ class AccountManager(private val context: Context) {
|
||||
// Clear SharedPreferences if this was the last logged account
|
||||
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
|
||||
if (lastLogged == publicKey) {
|
||||
sharedPrefs.edit().remove(KEY_LAST_LOGGED).commit()
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(KEY_LAST_LOGGED)
|
||||
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.accountDataStore.edit { it.clear() }
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(KEY_LAST_LOGGED)
|
||||
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun serializeAccounts(accounts: List<EncryptedAccount>): String {
|
||||
|
||||
@@ -17,16 +17,20 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Звонки
|
||||
- Android переведён на новый серверный сигналинг звонков: CALL -> ACCEPT -> KEY_EXCHANGE -> ACTIVE
|
||||
- Для звонков добавлена полная поддержка callId/joinToken (в CALL/ACCEPT/END_CALL)
|
||||
- Добавлена обработка RINGING_TIMEOUT с корректным завершением звонка
|
||||
- WebRTC пакет 0x1B обновлён под новый формат сервера (без лишних полей в payload)
|
||||
- Push звонка теперь пробрасывает callId/joinToken в CallManager для стабильного принятия до WebSocket
|
||||
Звонки и lockscreen
|
||||
- MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства
|
||||
- Во входящем полноэкранном звонке отключено автоматическое снятие keyguard
|
||||
- Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
|
||||
- При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
|
||||
|
||||
Стабильность
|
||||
- Улучшены диагностические логи звонков (callId/joinToken в state/log)
|
||||
- Обновлена совместимость Android с актуальными версиями desktop и rosetta-wss
|
||||
Сеть и протокол
|
||||
- Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback)
|
||||
- Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета
|
||||
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware
|
||||
|
||||
Стабильность UI
|
||||
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов)
|
||||
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -308,6 +308,14 @@ object CallManager {
|
||||
return CallActionResult.ACCOUNT_NOT_BOUND
|
||||
}
|
||||
}
|
||||
val restoredAuth = ProtocolManager.restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey = ownPublicKey,
|
||||
reason = "accept_incoming_call"
|
||||
)
|
||||
if (restoredAuth) {
|
||||
ProtocolManager.reconnectNowIfNeeded("accept_incoming_call")
|
||||
breadcrumb("acceptIncomingCall: auth restore requested")
|
||||
}
|
||||
|
||||
role = CallRole.CALLEE
|
||||
generateSessionKeys()
|
||||
@@ -327,7 +335,7 @@ object CallManager {
|
||||
// подождем немного пока идентификаторы звонка подтянутся.
|
||||
scope.launch {
|
||||
var sent = false
|
||||
for (attempt in 1..30) { // 30 * 200ms = 6 sec
|
||||
for (attempt in 1..60) { // 60 * 200ms = 12 sec
|
||||
val callIdNow = serverCallId.trim()
|
||||
val joinTokenNow = serverJoinToken.trim()
|
||||
if (callIdNow.isBlank() || joinTokenNow.isBlank()) {
|
||||
@@ -335,26 +343,25 @@ object CallManager {
|
||||
kotlinx.coroutines.delay(200)
|
||||
continue
|
||||
}
|
||||
if (ProtocolManager.isAuthenticated()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.ACCEPT,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey,
|
||||
callId = callIdNow,
|
||||
joinToken = joinTokenNow
|
||||
)
|
||||
breadcrumb(
|
||||
"acceptIncomingCall: ACCEPT sent (attempt #$attempt) " +
|
||||
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
|
||||
)
|
||||
sent = true
|
||||
break
|
||||
}
|
||||
breadcrumb("acceptIncomingCall: waiting for auth (attempt #$attempt)")
|
||||
kotlinx.coroutines.delay(200)
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.ACCEPT,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey,
|
||||
callId = callIdNow,
|
||||
joinToken = joinTokenNow
|
||||
)
|
||||
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
|
||||
// сразу при открытии сокета (или останется в очереди до onOpen).
|
||||
ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt")
|
||||
breadcrumb(
|
||||
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
|
||||
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
|
||||
)
|
||||
sent = true
|
||||
break
|
||||
}
|
||||
if (!sent) {
|
||||
breadcrumb("acceptIncomingCall: FAILED to send ACCEPT after 6s — resetting")
|
||||
breadcrumb("acceptIncomingCall: FAILED to send ACCEPT after 12s — resetting")
|
||||
resetSession(reason = "Failed to connect", notifyPeer = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ enum class ProtocolState {
|
||||
*/
|
||||
class Protocol(
|
||||
private val serverAddress: String,
|
||||
private val logger: (String) -> Unit = {}
|
||||
private val logger: (String) -> Unit = {},
|
||||
private val isNetworkAvailable: (() -> Boolean)? = null,
|
||||
private val onNetworkUnavailable: (() -> Unit)? = null
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RosettaProtocol"
|
||||
@@ -407,6 +409,14 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||
if (!networkReady) {
|
||||
log("📡 CONNECT DEFERRED: no active internet network")
|
||||
_lastError.value = "No active internet network"
|
||||
onNetworkUnavailable?.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Отменяем любые запланированные переподключения
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
@@ -447,6 +457,9 @@ class Protocol(
|
||||
isConnecting = false
|
||||
|
||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||
// Flush queue as soon as socket is open.
|
||||
// Auth-required packets will remain queued until handshake completes.
|
||||
flushPacketQueue()
|
||||
|
||||
// КРИТИЧНО: проверяем что не идет уже handshake
|
||||
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
|
||||
@@ -585,16 +598,31 @@ class Protocol(
|
||||
* (как в Архиве - сохраняем пакеты при любых проблемах с соединением)
|
||||
*/
|
||||
fun sendPacket(packet: Packet) {
|
||||
// Проверяем состояние соединения
|
||||
val currentState = _state.value
|
||||
val socket = webSocket
|
||||
val isConnected = _state.value == ProtocolState.AUTHENTICATED
|
||||
val socketReady = socket != null
|
||||
val authReady = handshakeComplete && currentState == ProtocolState.AUTHENTICATED
|
||||
val preAuthAllowedPacket =
|
||||
packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers
|
||||
val preAuthReady =
|
||||
preAuthAllowedPacket &&
|
||||
socketReady &&
|
||||
(
|
||||
currentState == ProtocolState.CONNECTED ||
|
||||
currentState == ProtocolState.HANDSHAKING ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
currentState == ProtocolState.AUTHENTICATED
|
||||
)
|
||||
val canSendNow =
|
||||
packet is PacketHandshake ||
|
||||
authReady ||
|
||||
preAuthReady
|
||||
|
||||
// Добавляем в очередь если:
|
||||
// 1. Handshake не завершён (кроме самого пакета handshake)
|
||||
// 2. WebSocket не подключен или null
|
||||
// 3. Не authenticated
|
||||
if ((!handshakeComplete && packet !is PacketHandshake) || socket == null || !isConnected) {
|
||||
log("📦 Queueing packet: ${packet.getPacketId()} (handshake=$handshakeComplete, socket=${socket != null}, state=${_state.value})")
|
||||
if (!canSendNow) {
|
||||
log(
|
||||
"📦 Queueing packet: ${packet.getPacketId()} " +
|
||||
"(handshake=$handshakeComplete, socket=$socketReady, state=$currentState, preAuthAllowed=$preAuthAllowedPacket)"
|
||||
)
|
||||
packetQueue.add(packet)
|
||||
return
|
||||
}
|
||||
@@ -642,7 +670,7 @@ class Protocol(
|
||||
packetQueue.clear()
|
||||
}
|
||||
log("📬 Flushing ${packets.size} queued packets")
|
||||
packets.forEach { sendPacketDirect(it) }
|
||||
packets.forEach { sendPacket(it) }
|
||||
}
|
||||
|
||||
private fun handleMessage(data: ByteArray) {
|
||||
@@ -714,6 +742,15 @@ class Protocol(
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatPeriodMs = 0L
|
||||
|
||||
if (!isManuallyClosed) {
|
||||
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||
if (!networkReady) {
|
||||
log("📡 RECONNECT DEFERRED: no active internet network, waiting for callback")
|
||||
onNetworkUnavailable?.invoke()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматический reconnect с защитой от бесконечных попыток
|
||||
if (!isManuallyClosed) {
|
||||
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
@@ -40,6 +44,7 @@ object ProtocolManager {
|
||||
private const val PACKET_SIGNAL_PEER = 0x1A
|
||||
private const val PACKET_WEB_RTC = 0x1B
|
||||
private const val PACKET_ICE_SERVERS = 0x1C
|
||||
private const val NETWORK_WAIT_TIMEOUT_MS = 20_000L
|
||||
|
||||
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
||||
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||
@@ -56,6 +61,10 @@ object ProtocolManager {
|
||||
@Volatile private var stateMonitoringStarted = false
|
||||
@Volatile private var syncRequestInFlight = false
|
||||
@Volatile private var syncRequestTimeoutJob: Job? = null
|
||||
private val networkReconnectLock = Any()
|
||||
@Volatile private var networkReconnectRegistered = false
|
||||
@Volatile private var networkReconnectCallback: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile private var networkReconnectTimeoutJob: Job? = null
|
||||
|
||||
// Guard: prevent duplicate FCM token subscribe within a single session
|
||||
@Volatile
|
||||
@@ -260,6 +269,7 @@ object ProtocolManager {
|
||||
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
|
||||
// New authenticated websocket session: always allow fresh push subscribe.
|
||||
lastSubscribedToken = null
|
||||
stopWaitingForNetwork("authenticated")
|
||||
onAuthenticated()
|
||||
}
|
||||
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
|
||||
@@ -973,12 +983,124 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasActiveInternet(): Boolean {
|
||||
val context = appContext ?: return true
|
||||
val cm =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return true
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private fun stopWaitingForNetwork(reason: String? = null) {
|
||||
val context = appContext ?: return
|
||||
val cm =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
val callback = synchronized(networkReconnectLock) {
|
||||
val current = networkReconnectCallback
|
||||
networkReconnectCallback = null
|
||||
networkReconnectRegistered = false
|
||||
networkReconnectTimeoutJob?.cancel()
|
||||
networkReconnectTimeoutJob = null
|
||||
current
|
||||
}
|
||||
if (callback != null) {
|
||||
runCatching { cm.unregisterNetworkCallback(callback) }
|
||||
if (!reason.isNullOrBlank()) {
|
||||
addLog("📡 NETWORK WATCH STOP: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForNetworkAndReconnect(reason: String) {
|
||||
if (hasActiveInternet()) {
|
||||
stopWaitingForNetwork("network already available")
|
||||
return
|
||||
}
|
||||
|
||||
val context = appContext ?: return
|
||||
val cm =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
|
||||
val alreadyRegistered = synchronized(networkReconnectLock) {
|
||||
if (networkReconnectRegistered) {
|
||||
true
|
||||
} else {
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (hasActiveInternet()) {
|
||||
addLog("📡 NETWORK AVAILABLE → reconnect")
|
||||
stopWaitingForNetwork("available")
|
||||
getProtocol().connect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
addLog("📡 NETWORK CAPABILITIES READY → reconnect")
|
||||
stopWaitingForNetwork("capabilities_changed")
|
||||
getProtocol().connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
networkReconnectCallback = callback
|
||||
networkReconnectRegistered = true
|
||||
false
|
||||
}
|
||||
}
|
||||
if (alreadyRegistered) {
|
||||
addLog("📡 NETWORK WAIT already active (reason=$reason)")
|
||||
return
|
||||
}
|
||||
|
||||
addLog("📡 NETWORK WAIT start (reason=$reason)")
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
cm.registerDefaultNetworkCallback(networkReconnectCallback!!)
|
||||
} else {
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, networkReconnectCallback!!)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
addLog("⚠️ NETWORK WAIT register failed: ${error.message}")
|
||||
stopWaitingForNetwork("register_failed")
|
||||
getProtocol().reconnectNowIfNeeded("network_wait_register_failed")
|
||||
}
|
||||
|
||||
networkReconnectTimeoutJob?.cancel()
|
||||
networkReconnectTimeoutJob =
|
||||
scope.launch {
|
||||
delay(NETWORK_WAIT_TIMEOUT_MS)
|
||||
if (!hasActiveInternet()) {
|
||||
addLog("⏱️ NETWORK WAIT timeout (${NETWORK_WAIT_TIMEOUT_MS}ms), reconnect fallback")
|
||||
stopWaitingForNetwork("timeout")
|
||||
getProtocol().reconnectNowIfNeeded("network_wait_timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Protocol instance
|
||||
*/
|
||||
fun getProtocol(): Protocol {
|
||||
if (protocol == null) {
|
||||
protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) }
|
||||
protocol =
|
||||
Protocol(
|
||||
serverAddress = SERVER_ADDRESS,
|
||||
logger = { msg -> addLog(msg) },
|
||||
isNetworkAvailable = { hasActiveInternet() },
|
||||
onNetworkUnavailable = { waitForNetworkAndReconnect("protocol_connect") }
|
||||
)
|
||||
}
|
||||
return protocol!!
|
||||
}
|
||||
@@ -999,6 +1121,11 @@ object ProtocolManager {
|
||||
* Connect to server
|
||||
*/
|
||||
fun connect() {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("connect")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("connect")
|
||||
getProtocol().connect()
|
||||
}
|
||||
|
||||
@@ -1006,6 +1133,11 @@ object ProtocolManager {
|
||||
* Trigger immediate reconnect on app foreground (skip waiting backoff timer).
|
||||
*/
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("reconnect:$reason")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("reconnect:$reason")
|
||||
getProtocol().reconnectNowIfNeeded(reason)
|
||||
}
|
||||
|
||||
@@ -1057,10 +1189,43 @@ object ProtocolManager {
|
||||
* Authenticate with server
|
||||
*/
|
||||
fun authenticate(publicKey: String, privateHash: String) {
|
||||
appContext?.let { context ->
|
||||
runCatching {
|
||||
val accountManager = AccountManager(context)
|
||||
accountManager.setLastLoggedPublicKey(publicKey)
|
||||
accountManager.setLastLoggedPrivateKeyHash(privateHash)
|
||||
}
|
||||
}
|
||||
val device = buildHandshakeDevice()
|
||||
getProtocol().startHandshake(publicKey, privateHash, device)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore auth handshake credentials from local account cache.
|
||||
* Used when process is awakened by push and UI unlock flow wasn't executed yet.
|
||||
*/
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean {
|
||||
val context = appContext ?: return false
|
||||
val accountManager = AccountManager(context)
|
||||
val publicKey =
|
||||
preferredPublicKey?.trim().orEmpty().ifBlank {
|
||||
accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
}
|
||||
val privateHash = accountManager.getLastLoggedPrivateKeyHash().orEmpty()
|
||||
if (publicKey.isBlank() || privateHash.isBlank()) {
|
||||
addLog(
|
||||
"⚠️ restoreAuthFromStoredCredentials skipped (pk=${publicKey.isNotBlank()} hash=${privateHash.isNotBlank()} reason=$reason)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
addLog("🔐 Restoring auth from cache reason=$reason pk=${shortKeyForLog(publicKey)}")
|
||||
authenticate(publicKey, privateHash)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашивает собственный профиль с сервера (username, name/title).
|
||||
* Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03)
|
||||
@@ -1554,6 +1719,7 @@ object ProtocolManager {
|
||||
* Disconnect and clear
|
||||
*/
|
||||
fun disconnect() {
|
||||
stopWaitingForNetwork("manual_disconnect")
|
||||
protocol?.disconnect()
|
||||
protocol?.clearCredentials()
|
||||
messageRepository?.clearInitialization()
|
||||
@@ -1571,6 +1737,7 @@ object ProtocolManager {
|
||||
* Destroy instance completely
|
||||
*/
|
||||
fun destroy() {
|
||||
stopWaitingForNetwork("destroy")
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
messageRepository?.clearInitialization()
|
||||
|
||||
@@ -488,6 +488,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
if (account.isNotBlank()) {
|
||||
CallManager.bindAccount(account)
|
||||
}
|
||||
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey = account,
|
||||
reason = "push_$reason"
|
||||
)
|
||||
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…")
|
||||
ProtocolManager.reconnectNowIfNeeded("push_$reason")
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
|
||||
|
||||
@@ -79,6 +79,12 @@ fun CallOverlay(
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onMinimize: (() -> Unit)? = null
|
||||
) {
|
||||
var lastVisibleState by remember { mutableStateOf<CallUiState?>(null) }
|
||||
if (state.isVisible) {
|
||||
lastVisibleState = state
|
||||
}
|
||||
val uiState = if (state.isVisible) state else (lastVisibleState ?: state)
|
||||
|
||||
val view = LocalView.current
|
||||
LaunchedEffect(state.isVisible) {
|
||||
if (state.isVisible && !view.isInEditMode) {
|
||||
@@ -104,10 +110,10 @@ fun CallOverlay(
|
||||
)
|
||||
) {
|
||||
// ── Top controls: minimize (left) + key cast QR (right) ──
|
||||
val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
|
||||
val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE
|
||||
val showKeyCast =
|
||||
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
|
||||
state.keyCast.isNotBlank()
|
||||
(uiState.phase == CallPhase.ACTIVE || uiState.phase == CallPhase.CONNECTING) &&
|
||||
uiState.keyCast.isNotBlank()
|
||||
|
||||
if (canMinimize || showKeyCast) {
|
||||
Row(
|
||||
@@ -136,7 +142,7 @@ fun CallOverlay(
|
||||
}
|
||||
|
||||
if (showKeyCast) {
|
||||
EncryptionKeyButton(keyHex = state.keyCast)
|
||||
EncryptionKeyButton(keyHex = uiState.keyCast)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
@@ -154,11 +160,11 @@ fun CallOverlay(
|
||||
) {
|
||||
// Avatar with rings
|
||||
CallAvatar(
|
||||
peerPublicKey = state.peerPublicKey,
|
||||
displayName = state.displayName,
|
||||
peerPublicKey = uiState.peerPublicKey,
|
||||
displayName = uiState.displayName,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showRings = state.phase != CallPhase.IDLE
|
||||
showRings = uiState.phase != CallPhase.IDLE
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
@@ -170,7 +176,7 @@ fun CallOverlay(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = state.displayName,
|
||||
text = uiState.displayName,
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -178,7 +184,7 @@ fun CallOverlay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(uiState.peerPublicKey)
|
||||
if (isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
VerifiedBadge(
|
||||
@@ -192,23 +198,23 @@ fun CallOverlay(
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Status with animated dots
|
||||
val showDots = state.phase == CallPhase.OUTGOING ||
|
||||
state.phase == CallPhase.CONNECTING ||
|
||||
state.phase == CallPhase.INCOMING
|
||||
val showDots = uiState.phase == CallPhase.OUTGOING ||
|
||||
uiState.phase == CallPhase.CONNECTING ||
|
||||
uiState.phase == CallPhase.INCOMING
|
||||
|
||||
if (showDots) {
|
||||
AnimatedDotsText(
|
||||
baseText = when (state.phase) {
|
||||
CallPhase.OUTGOING -> state.statusText.ifBlank { "Requesting" }
|
||||
CallPhase.CONNECTING -> state.statusText.ifBlank { "Connecting" }
|
||||
baseText = when (uiState.phase) {
|
||||
CallPhase.OUTGOING -> uiState.statusText.ifBlank { "Requesting" }
|
||||
CallPhase.CONNECTING -> uiState.statusText.ifBlank { "Connecting" }
|
||||
CallPhase.INCOMING -> "Ringing"
|
||||
else -> ""
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
} else if (state.phase == CallPhase.ACTIVE) {
|
||||
} else if (uiState.phase == CallPhase.ACTIVE) {
|
||||
Text(
|
||||
text = formatCallDuration(state.durationSec),
|
||||
text = formatCallDuration(uiState.durationSec),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 15.sp
|
||||
)
|
||||
@@ -226,7 +232,7 @@ fun CallOverlay(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state.phase,
|
||||
targetState = uiState.phase,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith
|
||||
(fadeOut(tween(150)) + slideOutVertically { it / 3 })
|
||||
@@ -235,8 +241,8 @@ fun CallOverlay(
|
||||
) { phase ->
|
||||
when (phase) {
|
||||
CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline)
|
||||
CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd)
|
||||
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd)
|
||||
CallPhase.ACTIVE -> ActiveButtons(uiState, onToggleMute, onToggleSpeaker, onEnd)
|
||||
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(uiState, onToggleSpeaker, onToggleMute, onEnd)
|
||||
CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ import com.rosetta.messenger.R
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private fun Float.isFiniteValue(): Boolean = !isNaN() && !isInfinite()
|
||||
|
||||
@Composable
|
||||
fun SharedMediaFastScrollOverlay(
|
||||
visible: Boolean,
|
||||
@@ -74,10 +76,11 @@ fun SharedMediaFastScrollOverlay(
|
||||
var trackHeightPx by remember { mutableIntStateOf(0) }
|
||||
var monthBubbleHeightPx by remember { mutableIntStateOf(0) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragProgress by remember { mutableFloatStateOf(progress.coerceIn(0f, 1f)) }
|
||||
val initialProgress = if (progress.isFiniteValue()) progress.coerceIn(0f, 1f) else 0f
|
||||
var dragProgress by remember { mutableFloatStateOf(initialProgress) }
|
||||
var hintVisible by remember(showHint) { mutableStateOf(showHint) }
|
||||
|
||||
val normalizedProgress = progress.coerceIn(0f, 1f)
|
||||
val normalizedProgress = if (progress.isFiniteValue()) progress.coerceIn(0f, 1f) else 0f
|
||||
|
||||
LaunchedEffect(showHint) {
|
||||
if (showHint) {
|
||||
@@ -96,14 +99,22 @@ fun SharedMediaFastScrollOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
val shownProgress = if (isDragging) dragProgress else normalizedProgress
|
||||
val trackTravelPx = (trackHeightPx - handleSizePx).coerceAtLeast(1f)
|
||||
val handleOffsetYPx = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx)
|
||||
val shownProgressRaw = if (isDragging) dragProgress else normalizedProgress
|
||||
val shownProgress =
|
||||
if (shownProgressRaw.isFiniteValue()) shownProgressRaw.coerceIn(0f, 1f) else 0f
|
||||
val rawTrackTravel = (trackHeightPx - handleSizePx)
|
||||
val trackTravelPx =
|
||||
if (rawTrackTravel.isFiniteValue()) rawTrackTravel.coerceAtLeast(1f) else 1f
|
||||
val rawHandleOffsetYPx = trackTravelPx * shownProgress
|
||||
val handleOffsetYPx =
|
||||
if (rawHandleOffsetYPx.isFiniteValue()) rawHandleOffsetYPx.coerceIn(0f, trackTravelPx)
|
||||
else 0f
|
||||
val latestShownProgress by rememberUpdatedState(shownProgress)
|
||||
val latestHandleOffsetYPx by rememberUpdatedState(handleOffsetYPx)
|
||||
val handleCenterYPx = handleOffsetYPx + handleSizePx / 2f
|
||||
val trackTopPx = ((rootHeightPx - trackHeightPx) / 2f).coerceAtLeast(0f)
|
||||
val bubbleY = (trackTopPx + handleCenterYPx - monthBubbleHeightPx / 2f).roundToInt()
|
||||
val bubbleYValue = trackTopPx + handleCenterYPx - monthBubbleHeightPx / 2f
|
||||
val bubbleY = if (bubbleYValue.isFiniteValue()) bubbleYValue.roundToInt() else 0
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -178,7 +189,12 @@ fun SharedMediaFastScrollOverlay(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset { IntOffset(0, handleOffsetYPx.roundToInt()) }
|
||||
.offset {
|
||||
val safeHandleOffset =
|
||||
if (handleOffsetYPx.isFiniteValue()) handleOffsetYPx.roundToInt()
|
||||
else 0
|
||||
IntOffset(0, safeHandleOffset)
|
||||
}
|
||||
.size(handleSize)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,26 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.utils.CrashReportManager
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private const val MAX_CRASH_PREVIEW_CHARS = 180_000
|
||||
private const val MAX_CRASH_PREVIEW_LINES = 2_500
|
||||
|
||||
private fun buildCrashPreview(content: String): String {
|
||||
if (content.length <= MAX_CRASH_PREVIEW_CHARS) return content
|
||||
val clipped = content.take(MAX_CRASH_PREVIEW_CHARS).trimEnd()
|
||||
return buildString(clipped.length + 128) {
|
||||
append(clipped)
|
||||
append("\n\n--- LOG TRUNCATED IN VIEW ---\n")
|
||||
append("Use the copy button to copy the full crash log.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран для просмотра crash logs
|
||||
*/
|
||||
@@ -269,6 +283,9 @@ private fun CrashDetailScreen(
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val crashPreview = remember(crashReport.content) {
|
||||
buildCrashPreview(crashReport.content)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -313,13 +330,15 @@ private fun CrashDetailScreen(
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = crashReport.content,
|
||||
text = crashPreview,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp
|
||||
lineHeight = 18.sp,
|
||||
maxLines = MAX_CRASH_PREVIEW_LINES,
|
||||
overflow = TextOverflow.Clip
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user