Релиз 1.4.7: фиксы lockscreen, звонков и стабильности

This commit is contained in:
2026-04-05 13:06:29 +05:00
parent 9e14724ae2
commit 9d04ec07e8
13 changed files with 406 additions and 131 deletions

View File

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

View File

@@ -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" />

View File

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

View File

@@ -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
)
}
}
}

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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 если есть

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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))
}
}

View File

@@ -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)
)
}

View File

@@ -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
)
}
}