Релиз 1.5.3: хотфиксы протокола, синка и больших логов

This commit is contained in:
2026-04-17 01:30:57 +05:00
parent 53e2119feb
commit 7521b9a11b
6 changed files with 148 additions and 35 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.5.2" val rosettaVersionName = "1.5.3"
val rosettaVersionCode = 54 // Increment on each release val rosettaVersionCode = 55 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -17,27 +17,10 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
- Перемотка голосовых полностью переработана в Telegram-style: drag по waveform и точный seek по отпусканию - Исправлен сценарий входа с нового устройства после отклонения верификации: повторный запрос теперь работает корректно без перезапуска приложения
- Устранены конфликты жестов у ГС: tap/drag/scrub больше не конфликтуют со swipe-to-reply и swipe-back - Стабилизирована синхронизация после ошибок обработки батча: добавлен безопасный retry без потери sync-курсора
- Голосовой плеер доработан: стабильный scrub в паузе, корректный keepPaused, более надежный прогресс - Исправлен вылет при открытии диалога с очень длинными сообщениями/логами
- Добавлена очередь ГС внутри диалога с автопереходом к следующему голосовому по хронологии - Исправлен вылет в Crash Details при копировании больших логов; для полного лога добавлен экспорт через Share
- Улучшена совместимость payload голосовых (hex/base64 decode fallback) и восстановление аудиофайла из кэша
- Исправлено позиционирование и clipping кнопки записи в input-панели
- Добавлен haptic при старте записи и обновлены иконки записи voice/video
- В сайдбаре ограничен список аккаунтов (до 3) для более чистого Telegram-like layout
- Исправлен transition emoji -> keyboard: убран «пустой» зазор при закрытии emoji-панели
- В selection header чата добавлена кнопка Pin/Unpin для выбранного сообщения
- В Forward-пикере всегда показывается Saved Messages (даже если self-диалог ещё не создан)
- Переработаны media-permissions в attach/media picker: корректный permanently denied flow с переходом в Settings
- Улучшена инициализация аккаунта после login/unlock/create-account, устранён race «Sync postponed until account is initialized»
- Доработана синхронизация профиля аккаунта (name/username/verified), включая замену placeholder-имён
- Исправлен критичный баг отправки после верификации нового устройства на втором девайсе
- Исправлен reconnect overflow: устранена отрицательная задержка (-2147483648000ms) и дубли disconnect/reconnect
- Улучшена обработка device verification (ACCEPT/DECLINE) и reconnect-логика протокола
- Звонки: добавлен proximity manager (экран гаснет возле уха), добавлен WAKE_LOCK, учтён speaker on/off
- Звонки: рингтон теперь учитывает системный ringer mode (silent/vibrate), снижены ложные звуковые срабатывания
- Убраны дубли CALL-attachments у callee: источник call-события теперь единый (каноничный от caller)
- Групповые сообщения: fallback для plaintext-пакетов без group key и расширенная диагностика decrypt-ошибок
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -394,7 +394,35 @@ class Protocol(
} }
} }
DeviceResolveSolution.DECLINE -> { DeviceResolveSolution.DECLINE -> {
log("⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)})") val stateAtDecline = _state.value
log(
"⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)}, state=$stateAtDecline)"
)
// Critical recovery: after DECLINE user may retry login without app restart.
// Keep socket session alive when possible, but leave DEVICE_VERIFICATION_REQUIRED
// state so next authenticate() is not ignored by startHandshake guards.
if (
stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
stateAtDecline == ProtocolState.HANDSHAKING
) {
launchLifecycleOperation("device_verification_declined") {
handshakeComplete = false
handshakeJob?.cancel()
packetQueue.clear()
if (webSocket != null) {
setState(
ProtocolState.CONNECTED,
"Device verification declined, waiting for retry"
)
} else {
setState(
ProtocolState.DISCONNECTED,
"Device verification declined without active socket"
)
}
}
}
} }
} }
} }

View File

@@ -133,6 +133,8 @@ object ProtocolManager {
// Tracks the tail of the sequential processing chain (like desktop's `tail` promise) // Tracks the tail of the sequential processing chain (like desktop's `tail` promise)
@Volatile private var inboundQueueDrainJob: Job? = null @Volatile private var inboundQueueDrainJob: Job? = null
private val inboundProcessingFailures = AtomicInteger(0) private val inboundProcessingFailures = AtomicInteger(0)
private val inboundTasksInCurrentBatch = AtomicInteger(0)
private val fullFailureBatchStreak = AtomicInteger(0)
private val syncBatchEndMutex = Mutex() private val syncBatchEndMutex = Mutex()
// iOS parity: outgoing message retry mechanism. // iOS parity: outgoing message retry mechanism.
@@ -646,8 +648,15 @@ object ProtocolManager {
private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean { private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean {
ensureInboundQueueDrainRunning() ensureInboundQueueDrainRunning()
val countAsBatchTask = syncBatchInProgress
if (countAsBatchTask) {
inboundTasksInCurrentBatch.incrementAndGet()
}
val result = inboundTaskChannel.trySend(block) val result = inboundTaskChannel.trySend(block)
if (result.isFailure) { if (result.isFailure) {
if (countAsBatchTask) {
inboundTasksInCurrentBatch.decrementAndGet()
}
markInboundProcessingFailure( markInboundProcessingFailure(
"Failed to enqueue inbound task", "Failed to enqueue inbound task",
result.exceptionOrNull() result.exceptionOrNull()
@@ -781,6 +790,8 @@ object ProtocolManager {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout() clearSyncRequestTimeout()
inboundProcessingFailures.set(0) inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
fullFailureBatchStreak.set(0)
addLog(reason) addLog(reason)
setSyncInProgress(false) setSyncInProgress(false)
retryWaitingMessages() retryWaitingMessages()
@@ -902,6 +913,7 @@ object ProtocolManager {
// subsequent 0x06 packets are dispatched by OkHttp's sequential callback. // subsequent 0x06 packets are dispatched by OkHttp's sequential callback.
setSyncInProgress(true) setSyncInProgress(true)
inboundProcessingFailures.set(0) inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
} }
SyncStatus.BATCH_END -> { SyncStatus.BATCH_END -> {
addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})") addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})")
@@ -920,10 +932,30 @@ object ProtocolManager {
return@launch return@launch
} }
val failuresInBatch = inboundProcessingFailures.getAndSet(0) val failuresInBatch = inboundProcessingFailures.getAndSet(0)
val tasksInBatch = inboundTasksInCurrentBatch.getAndSet(0)
val fullBatchFailure = tasksInBatch > 0 && failuresInBatch >= tasksInBatch
if (failuresInBatch > 0) { if (failuresInBatch > 0) {
addLog( addLog(
"⚠️ SYNC batch had $failuresInBatch processing error(s), continuing with desktop sync cursor behavior" "⚠️ SYNC batch had $failuresInBatch processing error(s) out of $tasksInBatch task(s)"
) )
if (fullBatchFailure) {
val streak = fullFailureBatchStreak.incrementAndGet()
val fallbackCursor = messageRepository?.getLastSyncTimestamp() ?: 0L
if (streak <= 2) {
addLog(
"🛟 SYNC full-batch failure ($failuresInBatch/$tasksInBatch), keeping cursor=$fallbackCursor and retrying batch (streak=$streak)"
)
sendSynchronize(fallbackCursor)
return@launch
}
addLog(
"⚠️ SYNC full-batch failure streak=$streak, advancing cursor to avoid deadlock"
)
} else {
fullFailureBatchStreak.set(0)
}
} else {
fullFailureBatchStreak.set(0)
} }
val repository = messageRepository val repository = messageRepository
// Desktop parity: save the cursor provided by BATCH_END and request next // Desktop parity: save the cursor provided by BATCH_END and request next

View File

@@ -675,6 +675,9 @@ class AppleEmojiTextView @JvmOverloads constructor(
companion object { companion object {
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000 private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
private const val HARD_TEXT_RENDER_LIMIT = 24_000
private const val HARD_TEXT_RENDER_HEAD = 16_000
private const val HARD_TEXT_RENDER_TAIL = 7_000
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native) // 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):") private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
@@ -693,6 +696,21 @@ class AppleEmojiTextView @JvmOverloads constructor(
private val MENTION_PATTERN = Pattern.compile("(?<![A-Za-z0-9_])@[A-Za-z0-9_]{1,32}(?![A-Za-z0-9_])") private val MENTION_PATTERN = Pattern.compile("(?<![A-Za-z0-9_])@[A-Za-z0-9_]{1,32}(?![A-Za-z0-9_])")
} }
private fun safeRenderText(rawText: String): String {
if (rawText.length <= HARD_TEXT_RENDER_LIMIT) return rawText
val skippedChars = rawText.length - HARD_TEXT_RENDER_HEAD - HARD_TEXT_RENDER_TAIL
val head = rawText.take(HARD_TEXT_RENDER_HEAD).trimEnd()
val tail = rawText.takeLast(HARD_TEXT_RENDER_TAIL).trimStart()
return buildString(head.length + tail.length + 160) {
append(head)
append("\n\n--- MESSAGE TRUNCATED FOR STABILITY ---\n")
append("Skipped ")
append(skippedChars.coerceAtLeast(0))
append(" chars. Open source log/file for full text.\n\n")
append(tail)
}
}
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
private var linksEnabled: Boolean = false private var linksEnabled: Boolean = false
private var mentionColorValue: Int = 0xFF54A9EB.toInt() private var mentionColorValue: Int = 0xFF54A9EB.toInt()
@@ -868,19 +886,20 @@ class AppleEmojiTextView @JvmOverloads constructor(
} }
fun setTextWithEmojis(text: String) { fun setTextWithEmojis(text: String) {
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD val renderText = safeRenderText(text)
val isLargeText = renderText.length > LARGE_TEXT_RENDER_THRESHOLD
val processMentions = mentionsEnabled && !isLargeText val processMentions = mentionsEnabled && !isLargeText
val processLinks = linksEnabled && !isLargeText val processLinks = linksEnabled && !isLargeText
val processEmoji = !isLargeText || containsEmojiHints(text) val processEmoji = !isLargeText || containsEmojiHints(renderText)
// Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн. // Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн.
if (!processEmoji && !processMentions && !processLinks) { if (!processEmoji && !processMentions && !processLinks) {
setText(text) setText(renderText)
return return
} }
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения // 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
val spannable = SpannableStringBuilder(text) val spannable = SpannableStringBuilder(renderText)
// Собираем все замены (чтобы не сбить индексы) // Собираем все замены (чтобы не сбить индексы)
data class EmojiMatch( data class EmojiMatch(
@@ -892,7 +911,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
val emojiMatches = mutableListOf<EmojiMatch>() val emojiMatches = mutableListOf<EmojiMatch>()
// 1. Ищем :emoji_XXXX: формат // 1. Ищем :emoji_XXXX: формат
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text) val codeMatcher = EMOJI_CODE_PATTERN.matcher(renderText)
while (codeMatcher.find()) { while (codeMatcher.find()) {
val rawCode = codeMatcher.group(1) ?: continue val rawCode = codeMatcher.group(1) ?: continue
val unified = val unified =
@@ -913,7 +932,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
val unicodeMatches = val unicodeMatches =
AppleEmojiAssetResolver.collectUnicodeMatches( AppleEmojiAssetResolver.collectUnicodeMatches(
context = context, context = context,
text = text, text = renderText,
occupiedRanges = occupiedRanges occupiedRanges = occupiedRanges
) )
unicodeMatches.forEach { match -> unicodeMatches.forEach { match ->

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.crashlogs package com.rosetta.messenger.ui.crashlogs
import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -25,12 +26,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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 androidx.core.content.FileProvider
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
import java.io.File
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_CHARS = 180_000
private const val MAX_CRASH_PREVIEW_LINES = 2_500 private const val MAX_CRASH_PREVIEW_LINES = 2_500
private const val MAX_CLIPBOARD_COPY_CHARS = 120_000
private fun buildCrashPreview(content: String): String { private fun buildCrashPreview(content: String): String {
if (content.length <= MAX_CRASH_PREVIEW_CHARS) return content if (content.length <= MAX_CRASH_PREVIEW_CHARS) return content
@@ -38,10 +42,41 @@ private fun buildCrashPreview(content: String): String {
return buildString(clipped.length + 128) { return buildString(clipped.length + 128) {
append(clipped) append(clipped)
append("\n\n--- LOG TRUNCATED IN VIEW ---\n") append("\n\n--- LOG TRUNCATED IN VIEW ---\n")
append("Use the copy button to copy the full crash log.") append("Use the share button to export the full crash log.")
} }
} }
private fun buildSafeClipboardLog(content: String): Pair<String, Boolean> {
if (content.length <= MAX_CLIPBOARD_COPY_CHARS) return content to false
val clipped = content.take(MAX_CLIPBOARD_COPY_CHARS).trimEnd()
val safeText = buildString(clipped.length + 220) {
append(clipped)
append("\n\n--- LOG TRUNCATED FOR CLIPBOARD ---\n")
append("Crash log is too large for clipboard transfer. ")
append("Use Share to export the full log file.")
}
return safeText to true
}
private fun shareCrashLog(context: android.content.Context, crashReport: CrashReportManager.CrashReport) {
val exportDir = File(context.cacheDir, "shared_crash_reports").apply { mkdirs() }
val safeBaseName = crashReport.fileName
.substringBeforeLast(".")
.replace(Regex("[^a-zA-Z0-9._-]"), "_")
.ifBlank { "crash_log" }
val exportFile = File(exportDir, "${safeBaseName}_${System.currentTimeMillis()}.txt")
exportFile.writeText(crashReport.content)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "Rosetta crash log: ${crashReport.fileName}")
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, "Share crash log"))
}
/** /**
* Экран для просмотра crash logs * Экран для просмотра crash logs
*/ */
@@ -300,13 +335,29 @@ private fun CrashDetailScreen(
actions = { actions = {
IconButton( IconButton(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(crashReport.content)) runCatching {
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show() val (safeClipboardText, wasTruncated) = buildSafeClipboardLog(crashReport.content)
clipboardManager.setText(AnnotatedString(safeClipboardText))
val message = if (wasTruncated) {
"Log is too large. Copied trimmed text. Use Share for full log."
} else {
"Full log copied"
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}.onFailure {
Toast.makeText(context, "Failed to copy log", Toast.LENGTH_SHORT).show()
}
} }
) { ) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log") Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log")
} }
IconButton(onClick = { /* TODO: Share */ }) { IconButton(onClick = {
runCatching {
shareCrashLog(context, crashReport)
}.onFailure {
Toast.makeText(context, "Failed to share crash log", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Default.Share, contentDescription = "Share") Icon(Icons.Default.Share, contentDescription = "Share")
} }
IconButton(onClick = { showDeleteDialog = true }) { IconButton(onClick = { showDeleteDialog = true }) {