Релиз 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
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.5.2"
val rosettaVersionCode = 54 // Increment on each release
val rosettaVersionName = "1.5.3"
val rosettaVersionCode = 55 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -17,27 +17,10 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- Перемотка голосовых полностью переработана в Telegram-style: drag по waveform и точный seek по отпусканию
- Устранены конфликты жестов у ГС: tap/drag/scrub больше не конфликтуют со swipe-to-reply и swipe-back
- Голосовой плеер доработан: стабильный scrub в паузе, корректный keepPaused, более надежный прогресс
- Добавлена очередь ГС внутри диалога с автопереходом к следующему голосовому по хронологии
- Улучшена совместимость 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-ошибок
- Исправлен сценарий входа с нового устройства после отклонения верификации: повторный запрос теперь работает корректно без перезапуска приложения
- Стабилизирована синхронизация после ошибок обработки батча: добавлен безопасный retry без потери sync-курсора
- Исправлен вылет при открытии диалога с очень длинными сообщениями/логами
- Исправлен вылет в Crash Details при копировании больших логов; для полного лога добавлен экспорт через Share
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -394,7 +394,35 @@ class Protocol(
}
}
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)
@Volatile private var inboundQueueDrainJob: Job? = null
private val inboundProcessingFailures = AtomicInteger(0)
private val inboundTasksInCurrentBatch = AtomicInteger(0)
private val fullFailureBatchStreak = AtomicInteger(0)
private val syncBatchEndMutex = Mutex()
// iOS parity: outgoing message retry mechanism.
@@ -646,8 +648,15 @@ object ProtocolManager {
private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean {
ensureInboundQueueDrainRunning()
val countAsBatchTask = syncBatchInProgress
if (countAsBatchTask) {
inboundTasksInCurrentBatch.incrementAndGet()
}
val result = inboundTaskChannel.trySend(block)
if (result.isFailure) {
if (countAsBatchTask) {
inboundTasksInCurrentBatch.decrementAndGet()
}
markInboundProcessingFailure(
"Failed to enqueue inbound task",
result.exceptionOrNull()
@@ -781,6 +790,8 @@ object ProtocolManager {
syncRequestInFlight = false
clearSyncRequestTimeout()
inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
fullFailureBatchStreak.set(0)
addLog(reason)
setSyncInProgress(false)
retryWaitingMessages()
@@ -902,6 +913,7 @@ object ProtocolManager {
// subsequent 0x06 packets are dispatched by OkHttp's sequential callback.
setSyncInProgress(true)
inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
}
SyncStatus.BATCH_END -> {
addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})")
@@ -920,10 +932,30 @@ object ProtocolManager {
return@launch
}
val failuresInBatch = inboundProcessingFailures.getAndSet(0)
val tasksInBatch = inboundTasksInCurrentBatch.getAndSet(0)
val fullBatchFailure = tasksInBatch > 0 && failuresInBatch >= tasksInBatch
if (failuresInBatch > 0) {
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
// Desktop parity: save the cursor provided by BATCH_END and request next

View File

@@ -675,6 +675,9 @@ class AppleEmojiTextView @JvmOverloads constructor(
companion object {
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
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
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 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 linksEnabled: Boolean = false
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
@@ -868,19 +886,20 @@ class AppleEmojiTextView @JvmOverloads constructor(
}
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 processLinks = linksEnabled && !isLargeText
val processEmoji = !isLargeText || containsEmojiHints(text)
val processEmoji = !isLargeText || containsEmojiHints(renderText)
// Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн.
if (!processEmoji && !processMentions && !processLinks) {
setText(text)
setText(renderText)
return
}
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
val spannable = SpannableStringBuilder(text)
val spannable = SpannableStringBuilder(renderText)
// Собираем все замены (чтобы не сбить индексы)
data class EmojiMatch(
@@ -892,7 +911,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
val emojiMatches = mutableListOf<EmojiMatch>()
// 1. Ищем :emoji_XXXX: формат
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
val codeMatcher = EMOJI_CODE_PATTERN.matcher(renderText)
while (codeMatcher.find()) {
val rawCode = codeMatcher.group(1) ?: continue
val unified =
@@ -913,7 +932,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
val unicodeMatches =
AppleEmojiAssetResolver.collectUnicodeMatches(
context = context,
text = text,
text = renderText,
occupiedRanges = occupiedRanges
)
unicodeMatches.forEach { match ->

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.crashlogs
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.background
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.rosetta.messenger.utils.CrashReportManager
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
private const val MAX_CRASH_PREVIEW_CHARS = 180_000
private const val MAX_CRASH_PREVIEW_LINES = 2_500
private const val MAX_CLIPBOARD_COPY_CHARS = 120_000
private fun buildCrashPreview(content: String): String {
if (content.length <= MAX_CRASH_PREVIEW_CHARS) return content
@@ -38,10 +42,41 @@ private fun buildCrashPreview(content: String): String {
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.")
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
*/
@@ -300,13 +335,29 @@ private fun CrashDetailScreen(
actions = {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(crashReport.content))
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show()
runCatching {
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")
}
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")
}
IconButton(onClick = { showDeleteDialog = true }) {