fix: Довести UI голосовых сообщений до Telegram: lock/blob, центр иконок и параллельная отправка
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
|
||||
delay(2000)
|
||||
hasCopied = false
|
||||
}
|
||||
scope.launch {
|
||||
delay(30_000)
|
||||
clipboardManager.setText(AnnotatedString(""))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -160,6 +160,10 @@ fun SafetyScreen(
|
||||
delay(2000)
|
||||
copiedPrivateKey = false
|
||||
}
|
||||
scope.launch {
|
||||
delay(30_000)
|
||||
cm.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||
}
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user