fix: Довести UI голосовых сообщений до Telegram: lock/blob, центр иконок и параллельная отправка

This commit is contained in:
2026-04-20 21:56:41 +05:00
parent b32d8ed061
commit 2e5dcfc99d
20 changed files with 529 additions and 320 deletions

View File

@@ -978,18 +978,16 @@ class MainActivity : FragmentActivity() {
override fun onResume() {
super.onResume()
// 🔥 Приложение стало видимым - отключаем уведомления
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true
// 🔔 Сбрасываем все уведомления из шторки при открытии приложения
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
protocolGateway.setAppInForeground(true)
protocolGateway.reconnectNowIfNeeded("activity_onResume")
}
override fun onPause() {
super.onPause()
// 🔥 Приложение ушло в background - включаем уведомления
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false
protocolGateway.setAppInForeground(false)
}
/** 🔔 Инициализация Firebase Cloud Messaging */

View File

@@ -55,6 +55,7 @@ interface ProtocolGateway : ProtocolRuntimePort {
fun notifyOwnProfileUpdated()
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
fun setAppInForeground(foreground: Boolean)
}
interface SessionCoordinator {

View File

@@ -43,6 +43,8 @@ class Protocol(
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val BACKGROUND_HEARTBEAT_INTERVAL_MS = 30_000L
private const val MAX_RECONNECT_ATTEMPTS = 10
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
private const val HEX_PREVIEW_BYTES = 64
private const val TEXT_PREVIEW_CHARS = 80
@@ -534,6 +536,8 @@ class Protocol(
// Heartbeat
private var heartbeatJob: Job? = null
@Volatile private var heartbeatPeriodMs: Long = 0L
@Volatile private var isAppInForeground: Boolean = true
private var serverHeartbeatIntervalSec: Int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var heartbeatOkSuppressedCount: Int = 0
@@ -615,38 +619,49 @@ class Protocol(
}
/**
* Start heartbeat to keep connection alive
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
* Start adaptive heartbeat to keep connection alive.
* Foreground: serverInterval / 2 (like desktop).
* Background: 30s to save battery.
*/
private fun startHeartbeat(intervalSeconds: Int) {
val normalizedServerIntervalSec =
serverHeartbeatIntervalSec =
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
val intervalMs =
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
return
}
heartbeatJob?.cancel()
heartbeatPeriodMs = intervalMs
lastHeartbeatOkLogAtMs = 0L
heartbeatOkSuppressedCount = 0
log(
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
"sending=${intervalMs / 1000}s, state=${_state.value}"
)
heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
sendHeartbeat()
while (isActive) {
val intervalMs = if (isAppInForeground) {
((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
} else {
BACKGROUND_HEARTBEAT_INTERVAL_MS
}
heartbeatPeriodMs = intervalMs
delay(intervalMs)
sendHeartbeat()
}
}
val fgMs = ((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
log(
"💓 HEARTBEAT START: server=${intervalSeconds}s, " +
"foreground=${fgMs / 1000}s, background=${BACKGROUND_HEARTBEAT_INTERVAL_MS / 1000}s, " +
"appForeground=$isAppInForeground, state=${_state.value}"
)
}
/**
* Notify protocol about app foreground/background state.
* Adjusts heartbeat interval to save battery in background.
*/
fun setAppInForeground(foreground: Boolean) {
if (isAppInForeground == foreground) return
isAppInForeground = foreground
log("💓 App foreground=$foreground, heartbeat will adjust on next tick")
}
/**
@@ -1101,22 +1116,22 @@ class Protocol(
}
}
// Автоматический reconnect с защитой от бесконечных попыток
// Автоматический reconnect с лимитом попыток
if (!isManuallyClosed) {
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
reconnectJob?.cancel()
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s.
// IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset.
// Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms).
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
// После 10 попыток — останавливаемся, ждём NetworkCallback или foreground resume
if (nextAttemptNumber > MAX_RECONNECT_ATTEMPTS) {
log("🛑 RECONNECT STOPPED: $nextAttemptNumber attempts exhausted, waiting for network change or foreground resume")
onNetworkUnavailable?.invoke()
return
}
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms")
if (nextAttemptNumber > 20) {
log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop")
}
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber/$MAX_RECONNECT_ATTEMPTS, delay=${delayMs}ms")
reconnectJob = scope.launch {
delay(delayMs)
@@ -1209,9 +1224,12 @@ class Protocol(
val now = System.currentTimeMillis()
log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, attempts=$reconnectAttempts, reason=$reason"
)
// Reset attempt counter — network changed or user returned to app
reconnectAttempts = 0
if (isManuallyClosed) {
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
return

View File

@@ -53,6 +53,9 @@ class ProtocolRuntime @Inject constructor(
override fun disconnect() = connectionControlApi.disconnect()
override fun setAppInForeground(foreground: Boolean) =
connectionControlApi.setAppInForeground(foreground)
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()

View File

@@ -80,6 +80,10 @@ class RuntimeConnectionControlFacade(
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
fun setAppInForeground(foreground: Boolean) {
runCatching { protocolInstanceManager.getOrCreateProtocol().setAppInForeground(foreground) }
}
fun getPrivateHashOrNull(): String? {
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
}

View File

@@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import com.rosetta.messenger.utils.RosettaDev1Log
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -37,6 +40,7 @@ data class TransportState(
object TransportManager {
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
private const val UPLOAD_ATTEMPT_TIMEOUT_MS = 45_000L
private var transportServer: String? = null
private var appContext: Context? = null
@@ -68,6 +72,7 @@ object TransportManager {
fun setTransportServer(server: String) {
val normalized = server.trim().trimEnd('/')
transportServer = normalized.ifBlank { null }
RosettaDev1Log.d("net/transport-server set=${transportServer.orEmpty()}")
}
/**
@@ -99,15 +104,37 @@ object TransportManager {
/**
* Retry с exponential backoff: 1с, 2с, 4с
*/
private suspend fun <T> withRetry(block: suspend () -> T): T {
private suspend fun <T> withRetry(
operation: String = "transport",
id: String = "-",
block: suspend () -> T
): T {
var lastException: Exception? = null
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: CancellationException) {
RosettaDev1Log.w(
"net/$operation cancelled id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES",
e
)
throw e
} catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
val shouldRetry = attempt < MAX_RETRIES - 1
if (shouldRetry) {
val backoffMs = INITIAL_BACKOFF_MS shl attempt
RosettaDev1Log.w(
"net/$operation retry id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"backoff=${backoffMs}ms reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}"
)
} else {
RosettaDev1Log.e(
"net/$operation failed id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
}
if (attempt < MAX_RETRIES - 1) {
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
}
@@ -121,6 +148,7 @@ object TransportManager {
*/
fun requestTransportServer() {
val packet = PacketRequestTransport()
RosettaDev1Log.d("net/transport-server request packet=0x0F")
ProtocolRuntimeAccess.get().sendPacket(packet)
}
@@ -172,6 +200,37 @@ object TransportManager {
})
}
private suspend fun awaitUploadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345"
@@ -188,13 +247,16 @@ object TransportManager {
*/
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
RosettaDev1Log.i(
"net/upload start id=${id.take(12)} server=$server bytes=${content.length}"
)
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0)
try {
withRetry {
withRetry(operation = "upload", id = id) {
val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong()
@@ -206,6 +268,7 @@ object TransportManager {
val source = okio.Buffer().write(contentBytes)
var uploaded = 0L
val bufferSize = 8 * 1024L
var lastProgressUpdateMs = 0L
while (true) {
val read = source.read(sink.buffer, bufferSize)
@@ -214,9 +277,14 @@ object TransportManager {
uploaded += read
sink.flush()
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
val now = System.currentTimeMillis()
val isLast = uploaded >= totalSize
if (isLast || now - lastProgressUpdateMs >= 200) {
lastProgressUpdateMs = now
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
}
}
}
@@ -231,44 +299,38 @@ object TransportManager {
.url("$server/u")
.post(requestBody)
.build()
val response = suspendCancellableCoroutine<Response> { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
val response =
try {
withTimeout(UPLOAD_ATTEMPT_TIMEOUT_MS) {
awaitUploadResponse(id, request)
}
} catch (timeout: CancellationException) {
if (timeout is kotlinx.coroutines.TimeoutCancellationException) {
activeUploadCalls.remove(id)?.cancel()
RosettaDev1Log.w(
"net/upload attempt-timeout id=${id.take(12)} timeoutMs=$UPLOAD_ATTEMPT_TIMEOUT_MS"
)
throw SocketTimeoutException(
"Upload timeout after ${UPLOAD_ATTEMPT_TIMEOUT_MS}ms"
)
}
throw timeout
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
val tag =
response.use { uploadResponse ->
if (!uploadResponse.isSuccessful) {
val errorBody = uploadResponse.body?.string()?.take(240).orEmpty()
RosettaDev1Log.e(
"net/upload http-fail id=${id.take(12)} code=${uploadResponse.code} body=$errorBody"
)
throw IOException("Upload failed: ${uploadResponse.code}")
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.code}")
}
val responseBody = response.body?.string()
?: throw IOException("Empty response")
val tag = org.json.JSONObject(responseBody).getString("t")
val responseBody = uploadResponse.body?.string()
?: throw IOException("Empty response")
org.json.JSONObject(responseBody).getString("t")
}
// Обновляем прогресс до 100%
_uploading.value = _uploading.value.map {
@@ -276,16 +338,22 @@ object TransportManager {
}
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
RosettaDev1Log.i("net/upload success id=${id.take(12)} tag=${tag.take(16)}")
tag
}
} catch (e: CancellationException) {
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
RosettaDev1Log.w("net/upload cancelled id=${id.take(12)}", e)
throw e
} catch (e: Exception) {
ProtocolRuntimeAccess.get().addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
RosettaDev1Log.e(
"net/upload failed id=${id.take(12)} reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
throw e
} finally {
activeUploadCalls.remove(id)?.cancel()
@@ -315,7 +383,7 @@ object TransportManager {
_downloading.value = _downloading.value + TransportState(id, 0)
try {
withRetry {
withRetry(operation = "download", id = id) {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
@@ -464,7 +532,7 @@ object TransportManager {
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try {
withRetry {
withRetry(operation = "download-raw-resume", id = id) {
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes)

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.utils.RosettaDev1Log
import kotlinx.coroutines.CoroutineScope
class OutgoingMessagePipelineService(
@@ -22,15 +23,21 @@ class OutgoingMessagePipelineService(
)
fun sendWithRetry(packet: PacketMessage) {
RosettaDev1Log.d(
"net/pipeline sendWithRetry msg=${packet.messageId.take(8)} " +
"to=${packet.toPublicKey.take(12)} from=${packet.fromPublicKey.take(12)}"
)
sendPacket(packet)
retryQueueService.register(packet)
}
fun resolveOutgoingRetry(messageId: String) {
RosettaDev1Log.d("net/pipeline resolveRetry msg=${messageId.take(8)}")
retryQueueService.resolve(messageId)
}
fun clearRetryQueue() {
RosettaDev1Log.d("net/pipeline clearRetryQueue")
retryQueueService.clear()
}
@@ -43,6 +50,7 @@ class OutgoingMessagePipelineService(
packet.fromPublicKey
}
val dialogKey = repository.getDialogKey(opponentKey)
RosettaDev1Log.w("net/pipeline markError msg=${messageId.take(8)} dialog=${dialogKey.take(16)}")
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
}
}

View File

@@ -88,6 +88,7 @@ internal class ProtocolDebugLogService(
private fun shouldPersistProtocolTrace(message: String): Boolean {
if (uiLogsEnabled) return true
if (message.contains("rosettadev1", ignoreCase = true)) return true
if (message.startsWith("") || message.startsWith("⚠️")) return true
if (message.contains("STATE CHANGE")) return true
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.utils.RosettaDev1Log
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -31,6 +32,7 @@ class RetryQueueService(
fun register(packet: PacketMessage) {
val messageId = packet.messageId
RosettaDev1Log.d("net/retry register msg=${messageId.take(8)}")
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingPackets[messageId] = packet
pendingOutgoingAttempts[messageId] = 0
@@ -38,6 +40,7 @@ class RetryQueueService(
}
fun resolve(messageId: String) {
RosettaDev1Log.d("net/retry resolve msg=${messageId.take(8)}")
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingRetryJobs.remove(messageId)
pendingOutgoingPackets.remove(messageId)
@@ -45,6 +48,7 @@ class RetryQueueService(
}
fun clear() {
RosettaDev1Log.d("net/retry clear size=${pendingOutgoingRetryJobs.size}")
pendingOutgoingRetryJobs.values.forEach { it.cancel() }
pendingOutgoingRetryJobs.clear()
pendingOutgoingPackets.clear()
@@ -63,6 +67,9 @@ class RetryQueueService(
val nowMs = System.currentTimeMillis()
val ageMs = nowMs - packet.timestamp
if (ageMs >= maxLifetimeMs) {
RosettaDev1Log.w(
"net/retry expired msg=${messageId.take(8)} age=${ageMs}ms"
)
addLog(
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
)
@@ -72,6 +79,9 @@ class RetryQueueService(
}
if (attempts >= maxRetryAttempts) {
RosettaDev1Log.w(
"net/retry exhausted msg=${messageId.take(8)} attempts=$attempts"
)
addLog(
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
)
@@ -81,6 +91,7 @@ class RetryQueueService(
}
if (!isAuthenticated()) {
RosettaDev1Log.w("net/retry deferred-not-auth msg=${messageId.take(8)}")
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
resolve(messageId)
return@launch
@@ -88,6 +99,9 @@ class RetryQueueService(
val nextAttempt = attempts + 1
pendingOutgoingAttempts[messageId] = nextAttempt
RosettaDev1Log.i(
"net/retry resend msg=${messageId.take(8)} attempt=$nextAttempt/$maxRetryAttempts"
)
addLog("🔄 Retrying message ${messageId.take(8)}, attempt $nextAttempt")
sendPacket(packet)
schedule(messageId)

View File

@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -111,10 +113,31 @@ fun DeviceConfirmScreen(
val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope()
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(
lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
)
}
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
iterations = LottieConstants.IterateForever,
isPlaying = isResumed
)
val localDeviceName = remember {

View File

@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
delay(2000)
hasCopied = false
}
scope.launch {
delay(30_000)
clipboardManager.setText(AnnotatedString(""))
}
}
) {
Icon(

View File

@@ -20,6 +20,7 @@ import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.utils.RosettaDev1Log
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.CancellationException
@@ -134,11 +135,13 @@ class VoiceRecordingViewModel internal constructor(
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
RosettaDev1Log.w("voice-send slot busy: request dropped")
return
}
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
RosettaDev1Log.w("voice-send payload invalid: empty normalized voice")
chatViewModel.releaseSendSlot()
return
}
@@ -146,6 +149,13 @@ class VoiceRecordingViewModel internal constructor(
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp"
val senderShort = sendContext.sender.take(12)
val recipientShort = sendContext.recipient.take(12)
RosettaDev1Log.i(
"voice-send start msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"from=$senderShort to=$recipientShort dur=${voicePayload.durationSec}s " +
"waves=${voicePayload.normalizedWaves.size} blobLen=${voicePayload.normalizedVoiceHex.length}"
)
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
@@ -178,6 +188,10 @@ class VoiceRecordingViewModel internal constructor(
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = (sendContext.sender == sendContext.recipient)
RosettaDev1Log.d(
"voice-send upload begin msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"saved=$isSavedMessages"
)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
@@ -187,6 +201,10 @@ class VoiceRecordingViewModel internal constructor(
isSavedMessages = isSavedMessages
)
)
RosettaDev1Log.d(
"voice-send upload done msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"tag=${uploadResult.transportTag.take(16)} server=${uploadResult.transportServer}"
)
val voiceAttachment =
MessageAttachment(
@@ -212,6 +230,10 @@ class VoiceRecordingViewModel internal constructor(
isSavedMessages = isSavedMessages
)
)
RosettaDev1Log.i(
"voice-send packet dispatched msg=${messageId.take(8)} " +
"saved=$isSavedMessages attachments=1"
)
runCatching {
AttachmentFileManager.saveAttachment(
@@ -275,7 +297,13 @@ class VoiceRecordingViewModel internal constructor(
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (_: Exception) {
RosettaDev1Log.i("voice-send done msg=${messageId.take(8)} status=queued")
} catch (e: Exception) {
RosettaDev1Log.e(
"voice-send failed msg=${messageId.take(8)} " +
"from=${sendContext.sender.take(12)} to=${sendContext.recipient.take(12)}",
e
)
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
@@ -288,6 +316,7 @@ class VoiceRecordingViewModel internal constructor(
opponentPublicKey = sendContext.recipient
)
} finally {
RosettaDev1Log.d("voice-send slot released msg=${messageId.take(8)}")
chatViewModel.releaseSendSlot()
}
}

View File

@@ -159,7 +159,9 @@ private val avatarColorsDark =
// Cache для цветов аватаров
data class AvatarColors(val textColor: Color, val backgroundColor: Color)
private val avatarColorCache = mutableMapOf<String, AvatarColors>()
private val avatarColorCache = object : LinkedHashMap<String, AvatarColors>(128, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AvatarColors>?) = size > 500
}
/**
* Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative
@@ -184,7 +186,9 @@ fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors {
}
// Cache для инициалов
private val initialsCache = mutableMapOf<String, String>()
private val initialsCache = object : LinkedHashMap<String, String>(128, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?) = size > 500
}
fun getInitials(name: String): String {
return initialsCache.getOrPut(name) {

View File

@@ -94,18 +94,6 @@ internal class MessagesCoordinator(
return
}
if (chatViewModel.isSendSlotBusy()) {
logSendBlocked(
reason = "is_sending",
textLength = text.length,
hasReply = replyMsgsToSend.isNotEmpty(),
recipient = recipient,
sender = sender,
hasPrivateKey = true
)
return
}
val command =
SendCommand(
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
@@ -130,18 +118,6 @@ internal class MessagesCoordinator(
return
}
if (!chatViewModel.tryAcquireSendSlot()) {
logSendBlocked(
reason = "is_sending",
textLength = command.text.length,
hasReply = command.replyMessages.isNotEmpty(),
recipient = command.recipientPublicKey,
sender = command.senderPublicKey,
hasPrivateKey = true
)
return
}
val messageId = command.messageId
val timestamp = command.timestamp
val fallbackName = chatViewModel.replyFallbackName()
@@ -443,7 +419,6 @@ internal class MessagesCoordinator(
opponentPublicKey = command.recipientPublicKey
)
} finally {
chatViewModel.releaseSendSlot()
triggerPendingTextSendIfReady("send_finished")
}
}
@@ -521,7 +496,7 @@ internal class MessagesCoordinator(
val recipientReady = chatViewModel.currentRecipientForSend() != null
val keysReady = chatViewModel.hasRuntimeKeysForSend()
if (!recipientReady || !keysReady || chatViewModel.isSendSlotBusy()) return
if (!recipientReady || !keysReady) return
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
clearPendingRecovery(cancelJob = true)

View File

@@ -14,9 +14,6 @@ import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
@@ -768,14 +765,20 @@ private fun LockIcon(
// ── Pause/Resume transform in locked mode ──
if (pauseTransform > 0.01f) {
val pausePlayCenterY =
if (lockedOrPaused) {
pillTop + pillH * 0.5f
} else {
bodyCy
}
if (isPaused) {
val triW = 10f * dp1
val triH = 12f * dp1
val left = bodyCx - triW * 0.35f
val top = bodyCy - triH / 2f
val left = bodyCx - triW / 2f
val top = pausePlayCenterY - triH / 2f
val playPath = Path().apply {
moveTo(left, top)
lineTo(left + triW, bodyCy)
lineTo(left + triW, pausePlayCenterY)
lineTo(left, top + triH)
close()
}
@@ -790,13 +793,13 @@ private fun LockIcon(
val barRadius = 1.5f * dp1
drawRoundRect(
color = iconColor.copy(alpha = pauseTransform),
topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f),
topLeft = Offset(bodyCx - gap - barW, pausePlayCenterY - barH / 2f),
size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
)
drawRoundRect(
color = iconColor.copy(alpha = pauseTransform),
topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f),
topLeft = Offset(bodyCx + gap, pausePlayCenterY - barH / 2f),
size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
)
@@ -951,130 +954,6 @@ private fun LockTooltip(
}
}
@Composable
private fun VoiceWaveformBar(
waves: List<Float>,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val barColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
val barWidthDp = 2.dp
val barGapDp = 1.dp
val minBarHeightDp = 2.dp
val maxBarHeightDp = 20.dp
Canvas(modifier = modifier.height(maxBarHeightDp)) {
val barWidthPx = barWidthDp.toPx()
val barGapPx = barGapDp.toPx()
val minH = minBarHeightDp.toPx()
val maxH = maxBarHeightDp.toPx()
val totalBarWidth = barWidthPx + barGapPx
val maxBars = (size.width / totalBarWidth).toInt().coerceAtLeast(1)
val displayWaves = if (waves.size > maxBars) waves.takeLast(maxBars) else waves
val cy = size.height / 2f
displayWaves.forEachIndexed { index, level ->
val barH = minH + (maxH - minH) * level.coerceIn(0f, 1f)
val x = (maxBars - displayWaves.size + index) * totalBarWidth
drawRoundRect(
color = barColor,
topLeft = Offset(x, cy - barH / 2f),
size = androidx.compose.ui.geometry.Size(barWidthPx, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidthPx / 2f)
)
}
}
}
/**
* Telegram-exact locked recording controls.
*
* Layout: [CANCEL text-button] [⏸/▶ circle button]
*
* - CANCEL = blue text (15sp bold, uppercase), clickable — cancels recording
* - ⏸ = small circle button (36dp), toggles pause/resume
* - No separate delete icon — CANCEL IS delete
*
* Reference: ChatActivityEnterView recordedAudioPanel + SlideTextView cancelToProgress
*/
@Composable
private fun RecordLockedControls(
isPaused: Boolean,
isDarkTheme: Boolean,
onDelete: () -> Unit,
onTogglePause: () -> Unit,
modifier: Modifier = Modifier
) {
val cancelColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.15f) else Color(0xFF2D9CFF).copy(alpha = 0.1f)
val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// CANCEL text button — Telegram: blue bold uppercase
Text(
text = "CANCEL",
color = cancelColor,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onDelete() }
.padding(horizontal = 4.dp, vertical = 8.dp)
)
// Pause/Resume button — circle with icon
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(pauseBgColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onTogglePause() },
contentAlignment = Alignment.Center
) {
if (isPaused) {
// Play triangle
Canvas(modifier = Modifier.size(14.dp)) {
val path = Path().apply {
moveTo(size.width * 0.2f, 0f)
lineTo(size.width, size.height / 2f)
lineTo(size.width * 0.2f, size.height)
close()
}
drawPath(path, color = pauseIconColor)
}
} else {
// Pause bars
Canvas(modifier = Modifier.size(14.dp)) {
val barW = size.width * 0.22f
val gap = size.width * 0.14f
drawRoundRect(
color = pauseIconColor,
topLeft = Offset(size.width / 2f - gap - barW, size.height * 0.1f),
size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
)
drawRoundRect(
color = pauseIconColor,
topLeft = Offset(size.width / 2f + gap, size.height * 0.1f),
size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
)
}
}
}
}
}
/**
* Message input bar and related components
* Extracted from ChatDetailScreen.kt for better organization
@@ -2588,8 +2467,8 @@ fun MessageInputBar(
// ── Telegram-exact recording layout ──
// RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob]
// LOCKED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Pause above
// PAUSED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Play above
// LOCKED: [Cancel] [Timer] ... [Circle=Send] + Lock→Pause above
// PAUSED: [Cancel] [Timer] ... [Circle=Send] + Lock→Play above
// Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact)
Box(
modifier = Modifier
@@ -2679,44 +2558,51 @@ fun MessageInputBar(
label = "record_panel_mode"
) { locked ->
if (locked) {
// ── LOCKED/PAUSED panel (Telegram: recordedAudioPanel) ──
// [Delete 44dp] [Waveform fills rest]
Row(
// ── LOCKED/PAUSED panel (Telegram-like) ──
// No live waveform in input row: show Cancel + Timer.
val lockedTimerMs =
if (isVoicePaused && voicePausedElapsedMs > 0L) {
voicePausedElapsedMs
} else {
voiceElapsedMs
}
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(24.dp))
.background(recordingPanelColor),
verticalAlignment = Alignment.CenterVertically
.background(recordingPanelColor)
) {
// Delete button — Telegram-style trash action
Text(
text = formatVoiceRecordTimer(lockedTimerMs),
color = recordingTextColor,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 14.dp)
)
Box(
modifier = Modifier
.size(recordingActionButtonBaseSize)
.align(Alignment.Center)
.height(40.dp)
.padding(horizontal = 14.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState")
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap CANCEL (locked/paused) mode=$recordMode state=$recordUiState")
stopVoiceRecording(send = false)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete recording",
tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D),
modifier = Modifier.size(20.dp)
Text(
text = "CANCEL",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = PrimaryBlue
)
}
// Waveform — Telegram: 32dp height, fills remaining width
VoiceWaveformBar(
waves = voiceWaves,
isDarkTheme = isDarkTheme,
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
)
}
} else {
// ── RECORDING panel ──
@@ -2943,11 +2829,35 @@ fun MessageInputBar(
.zIndex(5f),
contentAlignment = Alignment.Center
) {
// Blob: only during RECORDING
if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) {
// Blob: visible in RECORDING + LOCKED (and during lock transition)
// so locked state keeps the same "alive" blue bubbles behind the circle.
val showVoiceBlob =
(
recordUiState == RecordUiState.RECORDING ||
recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED ||
isLockTransitioning
) && !isVoiceCancelAnimating
val blobInLockedMode =
recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED ||
isLockTransitioning
val blobSlideProgress =
if (blobInLockedMode) {
1f
} else {
slideToCancelProgress
}
val blobVoiceLevel =
if (blobInLockedMode) {
maxOf(voiceLevel, 0.26f)
} else {
voiceLevel
}
if (showVoiceBlob) {
VoiceButtonBlob(
voiceLevel = voiceLevel,
slideToCancelProgress = slideToCancelProgress,
voiceLevel = blobVoiceLevel,
slideToCancelProgress = blobSlideProgress,
isDarkTheme = isDarkTheme,
modifier = Modifier
.size(recordingActionButtonBaseSize)

View File

@@ -36,6 +36,7 @@ import com.google.mlkit.vision.common.InputImage
import compose.icons.TablerIcons
import compose.icons.tablericons.X
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
data class QrScanResult(
val type: QrResultType,
@@ -68,6 +69,9 @@ fun QrScannerScreen(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val barcodeScanner = remember { BarcodeScanning.getClient() }
var hasCameraPermission by remember {
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
@@ -80,59 +84,141 @@ fun QrScannerScreen(
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
}
var scannedOnce by remember { mutableStateOf(false) }
var previewView by remember { mutableStateOf<PreviewView?>(null) }
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
var analysisUseCase by remember { mutableStateOf<ImageAnalysis?>(null) }
val scannedOnce = remember { AtomicBoolean(false) }
var isClosing by remember { mutableStateOf(false) }
fun requestClose() {
if (isClosing) return
isClosing = true
runCatching { analysisUseCase?.clearAnalyzer() }
onBack()
}
fun dispatchResult(result: QrScanResult) {
if (isClosing) return
isClosing = true
runCatching { analysisUseCase?.clearAnalyzer() }
onResult(result)
}
DisposableEffect(Unit) {
onDispose {
runCatching { analysisUseCase?.clearAnalyzer() }
runCatching {
val provider = cameraProvider
val preview = previewUseCase
val analyzer = analysisUseCase
if (provider != null && preview != null && analyzer != null) {
provider.unbind(preview, analyzer)
} else {
provider?.unbindAll()
}
}
runCatching { barcodeScanner.close() }
runCatching { cameraExecutor.shutdownNow() }
}
}
DisposableEffect(hasCameraPermission, lifecycleOwner, previewView, isClosing) {
val localPreviewView = previewView
if (!hasCameraPermission || localPreviewView == null || isClosing) {
onDispose {}
} else {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val listener = Runnable {
val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(localPreviewView.surfaceProvider)
}
val analyzer = ImageAnalysis.Builder()
.setTargetResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analyzer.setAnalyzer(cameraExecutor) { imageProxy ->
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
val mediaImage = imageProxy.image
if (mediaImage == null || scannedOnce.get() || isClosing) {
imageProxy.close()
return@setAnalyzer
}
val image =
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
if (scannedOnce.get() || isClosing) return@addOnSuccessListener
val parsedResult =
barcodes.firstNotNullOfOrNull { barcode ->
if (barcode.valueType != Barcode.TYPE_TEXT &&
barcode.valueType != Barcode.TYPE_URL
) {
return@firstNotNullOfOrNull null
}
val raw = barcode.rawValue ?: return@firstNotNullOfOrNull null
val parsed = parseQrContent(raw)
parsed.takeIf { it.type != QrResultType.UNKNOWN }
}
if (parsedResult != null && scannedOnce.compareAndSet(false, true)) {
dispatchResult(parsedResult)
}
}
.addOnCompleteListener { imageProxy.close() }
}
runCatching {
val oldPreview = previewUseCase
val oldAnalyzer = analysisUseCase
if (oldPreview != null && oldAnalyzer != null) {
provider.unbind(oldPreview, oldAnalyzer)
}
}
previewUseCase = preview
analysisUseCase = analyzer
runCatching {
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analyzer
)
}
}
cameraProviderFuture.addListener(listener, mainExecutor)
onDispose {
runCatching { analysisUseCase?.clearAnalyzer() }
runCatching {
val provider = cameraProvider
val preview = previewUseCase
val analyzer = analysisUseCase
if (provider != null && preview != null && analyzer != null) {
provider.unbind(preview, analyzer)
}
}
}
}
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (hasCameraPermission) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analyzer = ImageAnalysis.Builder()
.setTargetResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
val scanner = BarcodeScanning.getClient()
analyzer.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
val mediaImage = imageProxy.image
if (mediaImage != null && !scannedOnce) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
if (barcode.valueType == Barcode.TYPE_TEXT || barcode.valueType == Barcode.TYPE_URL) {
val raw = barcode.rawValue ?: continue
val result = parseQrContent(raw)
if (result.type != QrResultType.UNKNOWN && !scannedOnce) {
scannedOnce = true
onResult(result)
return@addOnSuccessListener
}
}
}
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analyzer
)
} catch (_: Exception) {}
}, ContextCompat.getMainExecutor(ctx))
previewView
PreviewView(ctx).also { created ->
previewView = created
}
},
update = { updated -> previewView = updated },
modifier = Modifier.fillMaxSize()
)
} else {
@@ -155,7 +241,7 @@ fun QrScannerScreen(
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
IconButton(onClick = ::requestClose) {
Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp))
}
Spacer(modifier = Modifier.weight(1f))

View File

@@ -249,6 +249,10 @@ fun BackupScreen(
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Seed Phrase", seedPhrase)
clipboard.setPrimaryClip(clip)
kotlinx.coroutines.MainScope().launch {
kotlinx.coroutines.delay(30_000)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", ""))
}
},
modifier = Modifier
.fillMaxWidth()

View File

@@ -160,6 +160,10 @@ fun SafetyScreen(
delay(2000)
copiedPrivateKey = false
}
scope.launch {
delay(30_000)
cm.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
},
textColor = textColor,

View File

@@ -45,13 +45,9 @@ fun SplashScreen(
label = "alpha"
)
val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat(
initialValue = 1f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
val pulseScale by animateFloatAsState(
targetValue = if (startAnimation) 1.08f else 1f,
animationSpec = tween(900, easing = FastOutSlowInEasing),
label = "pulseScale"
)

View File

@@ -0,0 +1,59 @@
package com.rosetta.messenger.utils
import android.util.Log
import com.rosetta.messenger.network.ProtocolRuntimeAccess
/**
* Unified diagnostics channel for ad-hoc troubleshooting.
*
* Logcat filter:
* - tag: rosettadev1
* - text: rosettadev1
*/
object RosettaDev1Log {
const val TAG: String = "rosettadev1"
private const val PREFIX = "rosettadev1 | "
private fun mirrorToProtocol(message: String, error: Throwable? = null) {
if (!ProtocolRuntimeAccess.isInstalled()) return
runCatching {
val protocolMessage =
if (error == null) {
"🧪 $PREFIX$message"
} else {
"🧪 $PREFIX$message | ${error.javaClass.simpleName}: ${error.message ?: "unknown"}"
}
ProtocolRuntimeAccess.get().addLog(protocolMessage)
}
}
fun d(message: String) {
Log.d(TAG, "$PREFIX$message")
mirrorToProtocol(message)
}
fun i(message: String) {
Log.i(TAG, "$PREFIX$message")
mirrorToProtocol(message)
}
fun w(message: String, error: Throwable? = null) {
val text = "$PREFIX$message"
if (error == null) {
Log.w(TAG, text)
} else {
Log.w(TAG, text, error)
}
mirrorToProtocol(message, error)
}
fun e(message: String, error: Throwable? = null) {
val text = "$PREFIX$message"
if (error == null) {
Log.e(TAG, text)
} else {
Log.e(TAG, text, error)
}
mirrorToProtocol(message, error)
}
}