Релиз 1.5.3: хотфиксы протокола, синка и больших логов
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user