Compare commits

...

3 Commits

13 changed files with 572 additions and 133 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.6" val rosettaVersionName = "1.4.7"
val rosettaVersionCode = 48 // Increment on each release val rosettaVersionCode = 49 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -46,9 +46,7 @@
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait" android:screenOrientation="portrait">
android:showWhenLocked="true"
android:turnScreenOn="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -1,7 +1,5 @@
package com.rosetta.messenger package com.rosetta.messenger
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -10,7 +8,6 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.* import androidx.compose.runtime.*
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
@@ -54,14 +51,8 @@ class IncomingCallActivity : ComponentActivity() {
) )
} }
// Dismiss keyguard // Важно: не снимаем keyguard автоматически.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Экран звонка может отображаться поверх lockscreen, но разблокировку делает только пользователь.
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
km?.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
}
try { try {
CallManager.initialize(applicationContext) CallManager.initialize(applicationContext)
@@ -77,6 +68,7 @@ class IncomingCallActivity : ComponentActivity() {
// Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE) // Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE)
var wasIncoming by remember { mutableStateOf(false) } var wasIncoming by remember { mutableStateOf(false) }
var lastPeerIdentity by remember { mutableStateOf(Triple("", "", "")) }
LaunchedEffect(callState.phase) { LaunchedEffect(callState.phase) {
callLog("phase changed: ${callState.phase}") callLog("phase changed: ${callState.phase}")
@@ -90,6 +82,17 @@ class IncomingCallActivity : ComponentActivity() {
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity // 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 только до первого реального входящего состояния. // Показываем INCOMING в IDLE только до первого реального входящего состояния.
// Иначе после Decline/END на мгновение мелькает "Unknown". // Иначе после Decline/END на мгновение мелькает "Unknown".
val shouldShowProvisionalIncoming = val shouldShowProvisionalIncoming =
@@ -101,6 +104,13 @@ class IncomingCallActivity : ComponentActivity() {
val displayState = if (shouldShowProvisionalIncoming) { val displayState = if (shouldShowProvisionalIncoming) {
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...") 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 { } else {
callState callState
} }

View File

@@ -463,58 +463,47 @@ class MainActivity : FragmentActivity() {
handleCallLockScreen(intent) 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?) { private fun handleCallLockScreen(intent: Intent?) {
val isCallIntent = intent?.getBooleanExtra( val isCallIntent = intent?.getBooleanExtra(
com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false
) == true ) == true
if (isCallIntent) { if (isCallIntent) {
openedForCall = true val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager
// Включаем экран и показываем поверх lock screen val isDeviceLocked = keyguardManager?.isDeviceLocked == true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { // Если экран заблокирован — не обходим auth и не показываем MainActivity поверх keyguard.
setShowWhenLocked(true) openedForCall = !isDeviceLocked
setTurnScreenOn(true)
} else { if (openedForCall) {
@Suppress("DEPRECATION") callIntentResetJob?.cancel()
window.addFlags( callIntentResetJob = lifecycleScope.launch {
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or com.rosetta.messenger.network.CallManager.state.collect { state ->
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) {
) openedForCall = false
} callIntentResetJob?.cancel()
// Убираем lock screen полностью callIntentResetJob = null
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
)
} }
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 val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private const val PREFS_NAME = "rosetta_account_prefs" private const val PREFS_NAME = "rosetta_account_prefs"
private const val KEY_LAST_LOGGED = "last_logged_public_key" 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 // Use SharedPreferences for last logged account - more reliable for immediate reads
@@ -44,12 +45,18 @@ class AccountManager(private val context: Context) {
return publicKey return publicKey
} }
fun getLastLoggedPrivateKeyHash(): String? {
return sharedPrefs.getString(KEY_LAST_LOGGED_PRIVATE_HASH, null)
}
// Synchronous write to SharedPreferences // Synchronous write to SharedPreferences
fun setLastLoggedPublicKey(publicKey: String) { 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 fun setLastLoggedPrivateKeyHash(privateKeyHash: String) {
val saved = sharedPrefs.getString(KEY_LAST_LOGGED, null) if (privateKeyHash.isBlank()) return
sharedPrefs.edit().putString(KEY_LAST_LOGGED_PRIVATE_HASH, privateKeyHash).apply()
} }
suspend fun saveAccount(account: EncryptedAccount) { suspend fun saveAccount(account: EncryptedAccount) {
@@ -98,6 +105,7 @@ class AccountManager(private val context: Context) {
context.accountDataStore.edit { preferences -> context.accountDataStore.edit { preferences ->
preferences[IS_LOGGED_IN] = false 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 // Clear SharedPreferences if this was the last logged account
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null) val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
if (lastLogged == publicKey) { 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() { suspend fun clearAll() {
context.accountDataStore.edit { it.clear() } 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 { private fun serializeAccounts(accounts: List<EncryptedAccount>): String {

View File

@@ -17,16 +17,20 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Звонки Звонки и lockscreen
- Android переведён на новый серверный сигналинг звонков: CALL -> ACCEPT -> KEY_EXCHANGE -> ACTIVE - MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства
- Для звонков добавлена полная поддержка callId/joinToken (в CALL/ACCEPT/END_CALL) - Во входящем полноэкранном звонке отключено автоматическое снятие keyguard
- Добавлена обработка RINGING_TIMEOUT с корректным завершением звонка - Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
- WebRTC пакет 0x1B обновлён под новый формат сервера (без лишних полей в payload) - При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
- Push звонка теперь пробрасывает callId/joinToken в CallManager для стабильного принятия до WebSocket
Стабильность Сеть и протокол
- Улучшены диагностические логи звонков (callId/joinToken в state/log) - Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback)
- Обновлена совместимость Android с актуальными версиями desktop и rosetta-wss - Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware
Стабильность UI
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов)
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -308,6 +308,14 @@ object CallManager {
return CallActionResult.ACCOUNT_NOT_BOUND 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 role = CallRole.CALLEE
generateSessionKeys() generateSessionKeys()
@@ -327,7 +335,7 @@ object CallManager {
// подождем немного пока идентификаторы звонка подтянутся. // подождем немного пока идентификаторы звонка подтянутся.
scope.launch { scope.launch {
var sent = false 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 callIdNow = serverCallId.trim()
val joinTokenNow = serverJoinToken.trim() val joinTokenNow = serverJoinToken.trim()
if (callIdNow.isBlank() || joinTokenNow.isBlank()) { if (callIdNow.isBlank() || joinTokenNow.isBlank()) {
@@ -335,26 +343,25 @@ object CallManager {
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
continue continue
} }
if (ProtocolManager.isAuthenticated()) { ProtocolManager.sendCallSignal(
ProtocolManager.sendCallSignal( signalType = SignalType.ACCEPT,
signalType = SignalType.ACCEPT, src = ownPublicKey,
src = ownPublicKey, dst = snapshot.peerPublicKey,
dst = snapshot.peerPublicKey, callId = callIdNow,
callId = callIdNow, joinToken = joinTokenNow
joinToken = joinTokenNow )
) // ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
breadcrumb( // сразу при открытии сокета (или останется в очереди до onOpen).
"acceptIncomingCall: ACCEPT sent (attempt #$attempt) " + ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt")
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}" breadcrumb(
) "acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
sent = true "callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
break )
} sent = true
breadcrumb("acceptIncomingCall: waiting for auth (attempt #$attempt)") break
kotlinx.coroutines.delay(200)
} }
if (!sent) { 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) resetSession(reason = "Failed to connect", notifyPeer = false)
} }
} }

View File

@@ -27,7 +27,9 @@ enum class ProtocolState {
*/ */
class Protocol( class Protocol(
private val serverAddress: String, 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 { companion object {
private const val TAG = "RosettaProtocol" private const val TAG = "RosettaProtocol"
@@ -407,6 +409,14 @@ class Protocol(
return 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?.cancel()
reconnectJob = null reconnectJob = null
@@ -447,6 +457,9 @@ class Protocol(
isConnecting = false isConnecting = false
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback") 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 // КРИТИЧНО: проверяем что не идет уже handshake
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) { if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
@@ -585,16 +598,31 @@ class Protocol(
* (как в Архиве - сохраняем пакеты при любых проблемах с соединением) * (как в Архиве - сохраняем пакеты при любых проблемах с соединением)
*/ */
fun sendPacket(packet: Packet) { fun sendPacket(packet: Packet) {
// Проверяем состояние соединения val currentState = _state.value
val socket = webSocket 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
// Добавляем в очередь если: if (!canSendNow) {
// 1. Handshake не завершён (кроме самого пакета handshake) log(
// 2. WebSocket не подключен или null "📦 Queueing packet: ${packet.getPacketId()} " +
// 3. Не authenticated "(handshake=$handshakeComplete, socket=$socketReady, state=$currentState, preAuthAllowed=$preAuthAllowedPacket)"
if ((!handshakeComplete && packet !is PacketHandshake) || socket == null || !isConnected) { )
log("📦 Queueing packet: ${packet.getPacketId()} (handshake=$handshakeComplete, socket=${socket != null}, state=${_state.value})")
packetQueue.add(packet) packetQueue.add(packet)
return return
} }
@@ -642,7 +670,7 @@ class Protocol(
packetQueue.clear() packetQueue.clear()
} }
log("📬 Flushing ${packets.size} queued packets") log("📬 Flushing ${packets.size} queued packets")
packets.forEach { sendPacketDirect(it) } packets.forEach { sendPacket(it) }
} }
private fun handleMessage(data: ByteArray) { private fun handleMessage(data: ByteArray) {
@@ -714,6 +742,15 @@ class Protocol(
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = 0L 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 с защитой от бесконечных попыток // Автоматический reconnect с защитой от бесконечных попыток
if (!isManuallyClosed) { if (!isManuallyClosed) {
// КРИТИЧНО: отменяем предыдущий reconnect job если есть // КРИТИЧНО: отменяем предыдущий reconnect job если есть

View File

@@ -1,6 +1,10 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context 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 android.os.Build
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
@@ -40,6 +44,7 @@ object ProtocolManager {
private const val PACKET_SIGNAL_PEER = 0x1A private const val PACKET_SIGNAL_PEER = 0x1A
private const val PACKET_WEB_RTC = 0x1B private const val PACKET_WEB_RTC = 0x1B
private const val PACKET_ICE_SERVERS = 0x1C 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. // Desktop parity: use the same primary WebSocket endpoint as desktop client.
private const val SERVER_ADDRESS = "wss://wss.rosetta.im" private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
@@ -56,6 +61,10 @@ object ProtocolManager {
@Volatile private var stateMonitoringStarted = false @Volatile private var stateMonitoringStarted = false
@Volatile private var syncRequestInFlight = false @Volatile private var syncRequestInFlight = false
@Volatile private var syncRequestTimeoutJob: Job? = null @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 // Guard: prevent duplicate FCM token subscribe within a single session
@Volatile @Volatile
@@ -260,6 +269,7 @@ object ProtocolManager {
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) { if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
// New authenticated websocket session: always allow fresh push subscribe. // New authenticated websocket session: always allow fresh push subscribe.
lastSubscribedToken = null lastSubscribedToken = null
stopWaitingForNetwork("authenticated")
onAuthenticated() onAuthenticated()
} }
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { 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 * Get or create Protocol instance
*/ */
fun getProtocol(): Protocol { fun getProtocol(): Protocol {
if (protocol == null) { 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!! return protocol!!
} }
@@ -999,6 +1121,11 @@ object ProtocolManager {
* Connect to server * Connect to server
*/ */
fun connect() { fun connect() {
if (!hasActiveInternet()) {
waitForNetworkAndReconnect("connect")
return
}
stopWaitingForNetwork("connect")
getProtocol().connect() getProtocol().connect()
} }
@@ -1006,6 +1133,11 @@ object ProtocolManager {
* Trigger immediate reconnect on app foreground (skip waiting backoff timer). * Trigger immediate reconnect on app foreground (skip waiting backoff timer).
*/ */
fun reconnectNowIfNeeded(reason: String = "foreground_resume") { fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
if (!hasActiveInternet()) {
waitForNetworkAndReconnect("reconnect:$reason")
return
}
stopWaitingForNetwork("reconnect:$reason")
getProtocol().reconnectNowIfNeeded(reason) getProtocol().reconnectNowIfNeeded(reason)
} }
@@ -1057,10 +1189,43 @@ object ProtocolManager {
* Authenticate with server * Authenticate with server
*/ */
fun authenticate(publicKey: String, privateHash: String) { fun authenticate(publicKey: String, privateHash: String) {
appContext?.let { context ->
runCatching {
val accountManager = AccountManager(context)
accountManager.setLastLoggedPublicKey(publicKey)
accountManager.setLastLoggedPrivateKeyHash(privateHash)
}
}
val device = buildHandshakeDevice() val device = buildHandshakeDevice()
getProtocol().startHandshake(publicKey, privateHash, device) 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). * Запрашивает собственный профиль с сервера (username, name/title).
* Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03) * Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03)
@@ -1554,6 +1719,7 @@ object ProtocolManager {
* Disconnect and clear * Disconnect and clear
*/ */
fun disconnect() { fun disconnect() {
stopWaitingForNetwork("manual_disconnect")
protocol?.disconnect() protocol?.disconnect()
protocol?.clearCredentials() protocol?.clearCredentials()
messageRepository?.clearInitialization() messageRepository?.clearInitialization()
@@ -1571,6 +1737,7 @@ object ProtocolManager {
* Destroy instance completely * Destroy instance completely
*/ */
fun destroy() { fun destroy() {
stopWaitingForNetwork("destroy")
protocol?.destroy() protocol?.destroy()
protocol = null protocol = null
messageRepository?.clearInitialization() messageRepository?.clearInitialization()

View File

@@ -73,10 +73,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val variants = buildDialogKeyVariants(senderPublicKey) val variants = buildDialogKeyVariants(senderPublicKey)
for (key in variants) { for (key in variants) {
notificationManager.cancel(getNotificationIdForChat(key)) notificationManager.cancel(getNotificationIdForChat(key))
lastNotifTimestamps.remove(key)
} }
// Fallback: некоторые серверные payload могут прийти без sender key. // Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог. // Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat("")) notificationManager.cancel(getNotificationIdForChat(""))
lastNotifTimestamps.remove("__no_sender__")
lastNotifTimestamps.remove("__simple__")
} }
private fun buildDialogKeyVariants(rawKey: String): Set<String> { private fun buildDialogKeyVariants(rawKey: String): Set<String> {
@@ -196,8 +199,19 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
when { when {
isReadEvent -> { isReadEvent -> {
if (!dialogKey.isNullOrBlank()) { val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
cancelNotificationForChat(applicationContext, dialogKey) if (keysToClear.isEmpty()) {
Log.d(TAG, "READ push received but no dialog key in payload: $data")
} else {
keysToClear.forEach { key ->
cancelNotificationForChat(applicationContext, key)
}
val titleHints = collectReadTitleHints(data, keysToClear)
cancelMatchingActiveNotifications(keysToClear, titleHints)
Log.d(
TAG,
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
)
} }
handledByData = true handledByData = true
} }
@@ -488,6 +502,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (account.isNotBlank()) { if (account.isNotBlank()) {
CallManager.bindAccount(account) CallManager.bindAccount(account)
} }
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
preferredPublicKey = account,
reason = "push_$reason"
)
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}")
ProtocolManager.reconnectNowIfNeeded("push_$reason") ProtocolManager.reconnectNowIfNeeded("push_$reason")
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
@@ -536,6 +555,156 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return null return null
} }
/**
* Builds a robust list of dialog keys for silent READ pushes.
* Server payloads may evolve (dialog/from/to/peer/group_* aliases), so we parse
* all known aliases and exclude current account public key.
*/
private fun collectReadDialogKeys(
data: Map<String, String>,
parsedDialogKey: String?,
parsedSenderKey: String?
): Set<String> {
val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim()
val candidates = linkedSetOf<String>()
fun addCandidate(raw: String?) {
val value = raw?.trim().orEmpty()
if (value.isBlank()) return
if (currentAccount.isNotBlank() && value == currentAccount) return
candidates.add(value)
}
addCandidate(parsedDialogKey)
addCandidate(parsedSenderKey)
addCandidate(firstNonBlank(data, "dialog", "dialog_key", "dialogPublicKey", "dialog_public_key"))
addCandidate(firstNonBlank(data, "peer", "peer_key", "peerPublicKey", "peer_public_key", "chat", "chat_key"))
addCandidate(firstNonBlank(data, "to", "toPublicKey", "to_public_key", "dst", "dst_public_key"))
addCandidate(firstNonBlank(data, "from", "fromPublicKey", "from_public_key", "src", "src_public_key"))
// Group aliases from some server payloads
val groupId = firstNonBlank(data, "group", "group_id", "groupId")
if (!groupId.isNullOrBlank()) {
addCandidate(groupId)
addCandidate("group:$groupId")
addCandidate("#group:$groupId")
}
return candidates
}
private fun collectReadTitleHints(
data: Map<String, String>,
dialogKeys: Set<String>
): Set<String> {
val hints = linkedSetOf<String>()
fun add(raw: String?) {
val value = raw?.trim().orEmpty()
if (value.isNotBlank()) hints.add(value)
}
add(firstNonBlank(data, "title", "sender_name", "sender_title", "from_title", "name"))
dialogKeys.forEach { key ->
add(resolveNameForKey(key))
}
return hints
}
/**
* Fallback for system-shown notifications with unknown IDs (FCM notification payload path).
* Matches active notifications by:
* 1) deterministic dialog hash id
* 2) dialog key/group key in notification extras/text
* 3) known dialog title hints
*/
private fun cancelMatchingActiveNotifications(
dialogKeys: Set<String>,
titleHints: Set<String>
) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val normalizedDialogKeys =
dialogKeys
.flatMap { key ->
buildDialogKeyVariants(key).toList() + key.trim()
}
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
val normalizedHints = titleHints.map { it.trim() }.filter { it.isNotEmpty() }.toSet()
if (normalizedDialogKeys.isEmpty() && normalizedHints.isEmpty()) return
runCatching {
manager.activeNotifications.forEach { sbn ->
val notification = sbn.notification ?: return@forEach
if (notification.channelId == "rosetta_calls") return@forEach
val title =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_TITLE)
?.toString()
?.trim()
.orEmpty()
val text =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_TEXT)
?.toString()
?.trim()
.orEmpty()
val bigText =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_BIG_TEXT)
?.toString()
?.trim()
.orEmpty()
val bag = mutableSetOf<String>()
if (title.isNotEmpty()) bag.add(title)
if (text.isNotEmpty()) bag.add(text)
if (bigText.isNotEmpty()) bag.add(bigText)
notification.extras?.keySet()?.forEach { extraKey ->
bag.add(extraKey)
val value = notification.extras?.get(extraKey)
when (value) {
is CharSequence -> bag.add(value.toString())
is String -> bag.add(value)
is Array<*> -> value.filterIsInstance<CharSequence>().forEach { bag.add(it.toString()) }
}
}
val bagLower = bag.map { it.lowercase(Locale.ROOT) }
val matchesDialogKey =
normalizedDialogKeys.any { key ->
val lowerKey = key.lowercase(Locale.ROOT)
bagLower.any { it.contains(lowerKey) }
}
val matchesHint =
normalizedHints.any { hint ->
title.equals(hint, ignoreCase = true) ||
text.contains(hint, ignoreCase = true) ||
bigText.contains(hint, ignoreCase = true)
}
val matchesDeterministicId =
normalizedDialogKeys.any { key ->
getNotificationIdForChat(key) == sbn.id
}
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
manager.cancel(sbn.tag, sbn.id)
Log.d(
TAG,
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
"channel=${notification.channelId} title='$title' " +
"matchId=$matchesDeterministicId matchKey=$matchesDialogKey matchHint=$matchesHint"
)
}
}
}.onFailure { error ->
Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
}
}
private fun isAvatarInNotificationsEnabled(): Boolean { private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching { return runCatching {
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {

View File

@@ -79,6 +79,12 @@ fun CallOverlay(
onToggleSpeaker: () -> Unit, onToggleSpeaker: () -> Unit,
onMinimize: (() -> Unit)? = null 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 val view = LocalView.current
LaunchedEffect(state.isVisible) { LaunchedEffect(state.isVisible) {
if (state.isVisible && !view.isInEditMode) { if (state.isVisible && !view.isInEditMode) {
@@ -104,10 +110,10 @@ fun CallOverlay(
) )
) { ) {
// ── Top controls: minimize (left) + key cast QR (right) ── // ── 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 = val showKeyCast =
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && (uiState.phase == CallPhase.ACTIVE || uiState.phase == CallPhase.CONNECTING) &&
state.keyCast.isNotBlank() uiState.keyCast.isNotBlank()
if (canMinimize || showKeyCast) { if (canMinimize || showKeyCast) {
Row( Row(
@@ -136,7 +142,7 @@ fun CallOverlay(
} }
if (showKeyCast) { if (showKeyCast) {
EncryptionKeyButton(keyHex = state.keyCast) EncryptionKeyButton(keyHex = uiState.keyCast)
} else { } else {
Spacer(modifier = Modifier.size(48.dp)) Spacer(modifier = Modifier.size(48.dp))
} }
@@ -154,11 +160,11 @@ fun CallOverlay(
) { ) {
// Avatar with rings // Avatar with rings
CallAvatar( CallAvatar(
peerPublicKey = state.peerPublicKey, peerPublicKey = uiState.peerPublicKey,
displayName = state.displayName, displayName = uiState.displayName,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showRings = state.phase != CallPhase.IDLE showRings = uiState.phase != CallPhase.IDLE
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -170,7 +176,7 @@ fun CallOverlay(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = state.displayName, text = uiState.displayName,
color = Color.White, color = Color.White,
fontSize = 26.sp, fontSize = 26.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -178,7 +184,7 @@ fun CallOverlay(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false) modifier = Modifier.weight(1f, fill = false)
) )
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey) val isOfficialByKey = MessageRepository.isSystemAccount(uiState.peerPublicKey)
if (isOfficialByKey) { if (isOfficialByKey) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
VerifiedBadge( VerifiedBadge(
@@ -192,23 +198,23 @@ fun CallOverlay(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// Status with animated dots // Status with animated dots
val showDots = state.phase == CallPhase.OUTGOING || val showDots = uiState.phase == CallPhase.OUTGOING ||
state.phase == CallPhase.CONNECTING || uiState.phase == CallPhase.CONNECTING ||
state.phase == CallPhase.INCOMING uiState.phase == CallPhase.INCOMING
if (showDots) { if (showDots) {
AnimatedDotsText( AnimatedDotsText(
baseText = when (state.phase) { baseText = when (uiState.phase) {
CallPhase.OUTGOING -> state.statusText.ifBlank { "Requesting" } CallPhase.OUTGOING -> uiState.statusText.ifBlank { "Requesting" }
CallPhase.CONNECTING -> state.statusText.ifBlank { "Connecting" } CallPhase.CONNECTING -> uiState.statusText.ifBlank { "Connecting" }
CallPhase.INCOMING -> "Ringing" CallPhase.INCOMING -> "Ringing"
else -> "" else -> ""
}, },
color = Color.White.copy(alpha = 0.6f) color = Color.White.copy(alpha = 0.6f)
) )
} else if (state.phase == CallPhase.ACTIVE) { } else if (uiState.phase == CallPhase.ACTIVE) {
Text( Text(
text = formatCallDuration(state.durationSec), text = formatCallDuration(uiState.durationSec),
color = Color.White.copy(alpha = 0.6f), color = Color.White.copy(alpha = 0.6f),
fontSize = 15.sp fontSize = 15.sp
) )
@@ -226,7 +232,7 @@ fun CallOverlay(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
AnimatedContent( AnimatedContent(
targetState = state.phase, targetState = uiState.phase,
transitionSpec = { transitionSpec = {
(fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith (fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith
(fadeOut(tween(150)) + slideOutVertically { it / 3 }) (fadeOut(tween(150)) + slideOutVertically { it / 3 })
@@ -235,8 +241,8 @@ fun CallOverlay(
) { phase -> ) { phase ->
when (phase) { when (phase) {
CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline) CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline)
CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd) CallPhase.ACTIVE -> ActiveButtons(uiState, onToggleMute, onToggleSpeaker, onEnd)
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd) CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(uiState, onToggleSpeaker, onToggleMute, onEnd)
CallPhase.IDLE -> Spacer(Modifier.height(1.dp)) CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
} }
} }

View File

@@ -52,6 +52,8 @@ import com.rosetta.messenger.R
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
private fun Float.isFiniteValue(): Boolean = !isNaN() && !isInfinite()
@Composable @Composable
fun SharedMediaFastScrollOverlay( fun SharedMediaFastScrollOverlay(
visible: Boolean, visible: Boolean,
@@ -74,10 +76,11 @@ fun SharedMediaFastScrollOverlay(
var trackHeightPx by remember { mutableIntStateOf(0) } var trackHeightPx by remember { mutableIntStateOf(0) }
var monthBubbleHeightPx by remember { mutableIntStateOf(0) } var monthBubbleHeightPx by remember { mutableIntStateOf(0) }
var isDragging by remember { mutableStateOf(false) } 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) } 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) { LaunchedEffect(showHint) {
if (showHint) { if (showHint) {
@@ -96,14 +99,22 @@ fun SharedMediaFastScrollOverlay(
} }
} }
val shownProgress = if (isDragging) dragProgress else normalizedProgress val shownProgressRaw = if (isDragging) dragProgress else normalizedProgress
val trackTravelPx = (trackHeightPx - handleSizePx).coerceAtLeast(1f) val shownProgress =
val handleOffsetYPx = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx) 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 latestShownProgress by rememberUpdatedState(shownProgress)
val latestHandleOffsetYPx by rememberUpdatedState(handleOffsetYPx) val latestHandleOffsetYPx by rememberUpdatedState(handleOffsetYPx)
val handleCenterYPx = handleOffsetYPx + handleSizePx / 2f val handleCenterYPx = handleOffsetYPx + handleSizePx / 2f
val trackTopPx = ((rootHeightPx - trackHeightPx) / 2f).coerceAtLeast(0f) 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( Box(
modifier = modifier modifier = modifier
@@ -178,7 +189,12 @@ fun SharedMediaFastScrollOverlay(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset { IntOffset(0, handleOffsetYPx.roundToInt()) } .offset {
val safeHandleOffset =
if (handleOffsetYPx.isFiniteValue()) handleOffsetYPx.roundToInt()
else 0
IntOffset(0, safeHandleOffset)
}
.size(handleSize) .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.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* 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 * Экран для просмотра crash logs
*/ */
@@ -269,6 +283,9 @@ private fun CrashDetailScreen(
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val crashPreview = remember(crashReport.content) {
buildCrashPreview(crashReport.content)
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -313,13 +330,15 @@ private fun CrashDetailScreen(
) )
) { ) {
Text( Text(
text = crashReport.content, text = crashPreview,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 18.sp lineHeight = 18.sp,
maxLines = MAX_CRASH_PREVIEW_LINES,
overflow = TextOverflow.Clip
) )
} }
} }