Compare commits
4 Commits
2bb3281ccf
...
152106eda1
| Author | SHA1 | Date | |
|---|---|---|---|
| 152106eda1 | |||
| b8c5529b29 | |||
| 9d04ec07e8 | |||
| 9e14724ae2 |
@@ -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.5"
|
val rosettaVersionName = "1.4.7"
|
||||||
val rosettaVersionCode = 47 // 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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -17,32 +17,20 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Звонки
|
Звонки и lockscreen
|
||||||
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
|
- MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства
|
||||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
|
- Во входящем полноэкранном звонке отключено автоматическое снятие keyguard
|
||||||
- Звонок больше не сбрасывается при переподключении WebSocket
|
- Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
|
||||||
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
|
- При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
|
||||||
- Автоматическая привязка аккаунта при принятии звонка из push-уведомления
|
|
||||||
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
|
|
||||||
- Убрано мелькание "Unknown" при завершении звонка
|
|
||||||
- Кнопка Decline теперь работает во всех фазах звонка
|
|
||||||
- Баннер активного звонка теперь отображается внутри диалога
|
|
||||||
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
|
|
||||||
- Защита от фантомных звонков при принятии на другом устройстве
|
|
||||||
- Корректное освобождение PeerConnection (dispose) при завершении звонка
|
|
||||||
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
|
|
||||||
- Диагностические логи звонков и уведомлений в rosettadev1
|
|
||||||
|
|
||||||
Уведомления
|
Сеть и протокол
|
||||||
- Аватарки и имена пользователей в уведомлениях о сообщениях и звонках
|
- Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback)
|
||||||
- Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications)
|
- Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета
|
||||||
- Сохранение FCM токена в rosettadev1 для диагностики
|
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware
|
||||||
- Поддержка tokenType и deviceId в push-подписке
|
|
||||||
|
|
||||||
Интерфейс
|
Стабильность UI
|
||||||
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
|
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов)
|
||||||
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
|
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
|
||||||
- Убраны старые обои, исправлено растяжение превью обоев
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 если есть
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user