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

View File

@@ -55,6 +55,7 @@ interface ProtocolGateway : ProtocolRuntimePort {
fun notifyOwnProfileUpdated() fun notifyOwnProfileUpdated()
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
fun setAppInForeground(foreground: Boolean)
} }
interface SessionCoordinator { 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 MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15 private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L 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 HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
private const val HEX_PREVIEW_BYTES = 64 private const val HEX_PREVIEW_BYTES = 64
private const val TEXT_PREVIEW_CHARS = 80 private const val TEXT_PREVIEW_CHARS = 80
@@ -534,6 +536,8 @@ class Protocol(
// Heartbeat // Heartbeat
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
@Volatile private var heartbeatPeriodMs: Long = 0L @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 lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var heartbeatOkSuppressedCount: Int = 0 @Volatile private var heartbeatOkSuppressedCount: Int = 0
@@ -615,38 +619,49 @@ class Protocol(
} }
/** /**
* Start heartbeat to keep connection alive * Start adaptive heartbeat to keep connection alive.
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом * Foreground: serverInterval / 2 (like desktop).
* Background: 30s to save battery.
*/ */
private fun startHeartbeat(intervalSeconds: Int) { private fun startHeartbeat(intervalSeconds: Int) {
val normalizedServerIntervalSec = serverHeartbeatIntervalSec =
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS 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() heartbeatJob?.cancel()
heartbeatPeriodMs = intervalMs
lastHeartbeatOkLogAtMs = 0L lastHeartbeatOkLogAtMs = 0L
heartbeatOkSuppressedCount = 0 heartbeatOkSuppressedCount = 0
log(
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
"sending=${intervalMs / 1000}s, state=${_state.value}"
)
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
sendHeartbeat() sendHeartbeat()
while (isActive) { while (isActive) {
val intervalMs = if (isAppInForeground) {
((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
} else {
BACKGROUND_HEARTBEAT_INTERVAL_MS
}
heartbeatPeriodMs = intervalMs
delay(intervalMs) delay(intervalMs)
sendHeartbeat() 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) { if (!isManuallyClosed) {
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
reconnectJob?.cancel() 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) 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 exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
val delayMs = minOf(1000L * (1L shl exponent), 30000L) val delayMs = minOf(1000L * (1L shl exponent), 30000L)
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms") log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber/$MAX_RECONNECT_ATTEMPTS, delay=${delayMs}ms")
if (nextAttemptNumber > 20) {
log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop")
}
reconnectJob = scope.launch { reconnectJob = scope.launch {
delay(delayMs) delay(delayMs)
@@ -1209,9 +1224,12 @@ class Protocol(
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
log( 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) { if (isManuallyClosed) {
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason") log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
return return

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -111,10 +113,31 @@ fun DeviceConfirmScreen(
val onExitState by rememberUpdatedState(onExit) val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope() 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 composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
val progress by animateLottieCompositionAsState( val progress by animateLottieCompositionAsState(
composition = composition, composition = composition,
iterations = LottieConstants.IterateForever iterations = LottieConstants.IterateForever,
isPlaying = isResumed
) )
val localDeviceName = remember { val localDeviceName = remember {

View File

@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
delay(2000) delay(2000)
hasCopied = false hasCopied = false
} }
scope.launch {
delay(30_000)
clipboardManager.setText(AnnotatedString(""))
}
} }
) { ) {
Icon( 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.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MediaUtils import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.utils.RosettaDev1Log
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -134,11 +135,13 @@ class VoiceRecordingViewModel internal constructor(
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) { fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) { if (!chatViewModel.tryAcquireSendSlot()) {
RosettaDev1Log.w("voice-send slot busy: request dropped")
return return
} }
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves) val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) { if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
RosettaDev1Log.w("voice-send payload invalid: empty normalized voice")
chatViewModel.releaseSendSlot() chatViewModel.releaseSendSlot()
return return
} }
@@ -146,6 +149,13 @@ class VoiceRecordingViewModel internal constructor(
val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp" 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( chatViewModel.addOutgoingMessageOptimistic(
ChatMessage( ChatMessage(
@@ -178,6 +188,10 @@ class VoiceRecordingViewModel internal constructor(
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = (sendContext.sender == sendContext.recipient) val isSavedMessages = (sendContext.sender == sendContext.recipient)
RosettaDev1Log.d(
"voice-send upload begin msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"saved=$isSavedMessages"
)
val uploadResult = val uploadResult =
chatViewModel.encryptAndUploadAttachment( chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand( EncryptAndUploadAttachmentCommand(
@@ -187,6 +201,10 @@ class VoiceRecordingViewModel internal constructor(
isSavedMessages = isSavedMessages 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 = val voiceAttachment =
MessageAttachment( MessageAttachment(
@@ -212,6 +230,10 @@ class VoiceRecordingViewModel internal constructor(
isSavedMessages = isSavedMessages isSavedMessages = isSavedMessages
) )
) )
RosettaDev1Log.i(
"voice-send packet dispatched msg=${messageId.take(8)} " +
"saved=$isSavedMessages attachments=1"
)
runCatching { runCatching {
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
@@ -275,7 +297,13 @@ class VoiceRecordingViewModel internal constructor(
accountPrivateKey = sendContext.privateKey, accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient 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) { withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR) chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
} }
@@ -288,6 +316,7 @@ class VoiceRecordingViewModel internal constructor(
opponentPublicKey = sendContext.recipient opponentPublicKey = sendContext.recipient
) )
} finally { } finally {
RosettaDev1Log.d("voice-send slot released msg=${messageId.take(8)}")
chatViewModel.releaseSendSlot() chatViewModel.releaseSendSlot()
} }
} }

View File

@@ -159,7 +159,9 @@ private val avatarColorsDark =
// Cache для цветов аватаров // Cache для цветов аватаров
data class AvatarColors(val textColor: Color, val backgroundColor: Color) 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 * Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative
@@ -184,7 +186,9 @@ fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors {
} }
// Cache для инициалов // 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 { fun getInitials(name: String): String {
return initialsCache.getOrPut(name) { return initialsCache.getOrPut(name) {

View File

@@ -94,18 +94,6 @@ internal class MessagesCoordinator(
return return
} }
if (chatViewModel.isSendSlotBusy()) {
logSendBlocked(
reason = "is_sending",
textLength = text.length,
hasReply = replyMsgsToSend.isNotEmpty(),
recipient = recipient,
sender = sender,
hasPrivateKey = true
)
return
}
val command = val command =
SendCommand( SendCommand(
messageId = UUID.randomUUID().toString().replace("-", "").take(32), messageId = UUID.randomUUID().toString().replace("-", "").take(32),
@@ -130,18 +118,6 @@ internal class MessagesCoordinator(
return 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 messageId = command.messageId
val timestamp = command.timestamp val timestamp = command.timestamp
val fallbackName = chatViewModel.replyFallbackName() val fallbackName = chatViewModel.replyFallbackName()
@@ -443,7 +419,6 @@ internal class MessagesCoordinator(
opponentPublicKey = command.recipientPublicKey opponentPublicKey = command.recipientPublicKey
) )
} finally { } finally {
chatViewModel.releaseSendSlot()
triggerPendingTextSendIfReady("send_finished") triggerPendingTextSendIfReady("send_finished")
} }
} }
@@ -521,7 +496,7 @@ internal class MessagesCoordinator(
val recipientReady = chatViewModel.currentRecipientForSend() != null val recipientReady = chatViewModel.currentRecipientForSend() != null
val keysReady = chatViewModel.hasRuntimeKeysForSend() val keysReady = chatViewModel.hasRuntimeKeysForSend()
if (!recipientReady || !keysReady || chatViewModel.isSendSlotBusy()) return if (!recipientReady || !keysReady) return
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger") chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
clearPendingRecovery(cancelJob = true) 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.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.airbnb.lottie.compose.rememberLottieComposition 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.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -768,14 +765,20 @@ private fun LockIcon(
// ── Pause/Resume transform in locked mode ── // ── Pause/Resume transform in locked mode ──
if (pauseTransform > 0.01f) { if (pauseTransform > 0.01f) {
val pausePlayCenterY =
if (lockedOrPaused) {
pillTop + pillH * 0.5f
} else {
bodyCy
}
if (isPaused) { if (isPaused) {
val triW = 10f * dp1 val triW = 10f * dp1
val triH = 12f * dp1 val triH = 12f * dp1
val left = bodyCx - triW * 0.35f val left = bodyCx - triW / 2f
val top = bodyCy - triH / 2f val top = pausePlayCenterY - triH / 2f
val playPath = Path().apply { val playPath = Path().apply {
moveTo(left, top) moveTo(left, top)
lineTo(left + triW, bodyCy) lineTo(left + triW, pausePlayCenterY)
lineTo(left, top + triH) lineTo(left, top + triH)
close() close()
} }
@@ -790,13 +793,13 @@ private fun LockIcon(
val barRadius = 1.5f * dp1 val barRadius = 1.5f * dp1
drawRoundRect( drawRoundRect(
color = iconColor.copy(alpha = pauseTransform), 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), size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius) cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
) )
drawRoundRect( drawRoundRect(
color = iconColor.copy(alpha = pauseTransform), 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), size = androidx.compose.ui.geometry.Size(barW, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius) 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 * Message input bar and related components
* Extracted from ChatDetailScreen.kt for better organization * Extracted from ChatDetailScreen.kt for better organization
@@ -2588,8 +2467,8 @@ fun MessageInputBar(
// ── Telegram-exact recording layout ── // ── Telegram-exact recording layout ──
// RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob] // RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob]
// LOCKED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Pause above // LOCKED: [Cancel] [Timer] ... [Circle=Send] + Lock→Pause above
// PAUSED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Play above // PAUSED: [Cancel] [Timer] ... [Circle=Send] + Lock→Play above
// Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact) // Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -2679,44 +2558,51 @@ fun MessageInputBar(
label = "record_panel_mode" label = "record_panel_mode"
) { locked -> ) { locked ->
if (locked) { if (locked) {
// ── LOCKED/PAUSED panel (Telegram: recordedAudioPanel) ── // ── LOCKED/PAUSED panel (Telegram-like) ──
// [Delete 44dp] [Waveform fills rest] // No live waveform in input row: show Cancel + Timer.
Row( val lockedTimerMs =
if (isVoicePaused && voicePausedElapsedMs > 0L) {
voicePausedElapsedMs
} else {
voiceElapsedMs
}
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(recordingPanelColor), .background(recordingPanelColor)
verticalAlignment = Alignment.CenterVertically
) { ) {
// 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( Box(
modifier = Modifier modifier = Modifier
.size(recordingActionButtonBaseSize) .align(Alignment.Center)
.height(40.dp)
.padding(horizontal = 14.dp)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null 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) stopVoiceRecording(send = false)
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Text(
imageVector = Icons.Default.Delete, text = "CANCEL",
contentDescription = "Delete recording", fontSize = 15.sp,
tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D), fontWeight = FontWeight.Bold,
modifier = Modifier.size(20.dp) color = PrimaryBlue
) )
} }
// Waveform — Telegram: 32dp height, fills remaining width
VoiceWaveformBar(
waves = voiceWaves,
isDarkTheme = isDarkTheme,
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
)
} }
} else { } else {
// ── RECORDING panel ── // ── RECORDING panel ──
@@ -2943,11 +2829,35 @@ fun MessageInputBar(
.zIndex(5f), .zIndex(5f),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Blob: only during RECORDING // Blob: visible in RECORDING + LOCKED (and during lock transition)
if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) { // 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( VoiceButtonBlob(
voiceLevel = voiceLevel, voiceLevel = blobVoiceLevel,
slideToCancelProgress = slideToCancelProgress, slideToCancelProgress = blobSlideProgress,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier modifier = Modifier
.size(recordingActionButtonBaseSize) .size(recordingActionButtonBaseSize)

View File

@@ -36,6 +36,7 @@ import com.google.mlkit.vision.common.InputImage
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.X import compose.icons.tablericons.X
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
data class QrScanResult( data class QrScanResult(
val type: QrResultType, val type: QrResultType,
@@ -68,6 +69,9 @@ fun QrScannerScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.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 { var hasCameraPermission by remember {
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
@@ -80,59 +84,141 @@ fun QrScannerScreen(
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) 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)) { Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (hasCameraPermission) { if (hasCameraPermission) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
val previewView = PreviewView(ctx) PreviewView(ctx).also { created ->
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) previewView = created
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
}, },
update = { updated -> previewView = updated },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else { } else {
@@ -155,7 +241,7 @@ fun QrScannerScreen(
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = ::requestClose) {
Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp)) Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp))
} }
Spacer(modifier = Modifier.weight(1f)) 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 clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Seed Phrase", seedPhrase) val clip = android.content.ClipData.newPlainText("Seed Phrase", seedPhrase)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
kotlinx.coroutines.MainScope().launch {
kotlinx.coroutines.delay(30_000)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", ""))
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

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

View File

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