From 9d04ec07e8f1573a950cc0d00aaad8c7da3371d1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 5 Apr 2026 13:06:29 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.4.7:=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20lockscreen,=20=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 4 +- .../rosetta/messenger/IncomingCallActivity.kt | 32 ++-- .../com/rosetta/messenger/MainActivity.kt | 69 +++---- .../rosetta/messenger/data/AccountManager.kt | 27 ++- .../rosetta/messenger/data/ReleaseNotes.kt | 22 ++- .../rosetta/messenger/network/CallManager.kt | 45 +++-- .../com/rosetta/messenger/network/Protocol.kt | 61 +++++-- .../messenger/network/ProtocolManager.kt | 169 +++++++++++++++++- .../push/RosettaFirebaseMessagingService.kt | 5 + .../messenger/ui/chats/calls/CallOverlay.kt | 46 ++--- .../SharedMediaFastScrollOverlay.kt | 30 +++- .../messenger/ui/crashlogs/CrashLogsScreen.kt | 23 ++- 13 files changed, 406 insertions(+), 131 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a2fce4..5ee0e5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1503d11..9a87c9b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt index d88e9f3..5812534 100644 --- a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt @@ -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 } diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index bff1edb..217fdc3 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/data/AccountManager.kt b/app/src/main/java/com/rosetta/messenger/data/AccountManager.kt index 1c95dbb..775caf7 100644 --- a/app/src/main/java/com/rosetta/messenger/data/AccountManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/AccountManager.kt @@ -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 @@ -43,13 +44,19 @@ class AccountManager(private val context: Context) { val publicKey = sharedPrefs.getString(KEY_LAST_LOGGED, null) 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 - - // Verify immediately - val saved = sharedPrefs.getString(KEY_LAST_LOGGED, null) + sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous + } + + 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): String { diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 6985865..763c683 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index b5f3dcd..aadb546 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -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) } } diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 272814f..f0a7813 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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" @@ -406,6 +408,14 @@ class Protocol( log("⚠️ Already connecting, skipping... (preventing duplicate connect)") 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() @@ -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,20 +598,35 @@ class Protocol( * (как в Архиве - сохраняем пакеты при любых проблемах с соединением) */ fun sendPacket(packet: Packet) { - // Проверяем состояние соединения + val currentState = _state.value val socket = webSocket - val isConnected = _state.value == ProtocolState.AUTHENTICATED - - // Добавляем в очередь если: - // 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})") + 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 + + if (!canSendNow) { + log( + "📦 Queueing packet: ${packet.getPacketId()} " + + "(handshake=$handshakeComplete, socket=$socketReady, state=$currentState, preAuthAllowed=$preAuthAllowedPacket)" + ) packetQueue.add(packet) return } - + sendPacketDirect(packet) } @@ -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 если есть diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index acd1ea0..0f831df 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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) { @@ -972,13 +982,125 @@ 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,9 +1189,42 @@ 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). @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 8fb94e9..27c8ae0 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -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}") diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index 4dd54b8..38f5a1a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -79,6 +79,12 @@ fun CallOverlay( onToggleSpeaker: () -> Unit, onMinimize: (() -> Unit)? = null ) { + var lastVisibleState by remember { mutableStateOf(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)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt index 83cd36d..60e5eed 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt @@ -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) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/crashlogs/CrashLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/crashlogs/CrashLogsScreen.kt index 6f6f94e..5f2d5c4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/crashlogs/CrashLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/crashlogs/CrashLogsScreen.kt @@ -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 ) } }