diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index cda5ac9..15a4928 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 */ diff --git a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt index 754ca90..138f273 100644 --- a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt @@ -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 + fun setAppInForeground(foreground: Boolean) } interface SessionCoordinator { diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 3e1cad8..3f9c849 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt index 6f106d2..6e116aa 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt index 717ff25..892a4a7 100644 --- a/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt @@ -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() } diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 210d468..3576aba 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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 withRetry(block: suspend () -> T): T { + private suspend fun 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 { 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) diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt index da2680d..85f2359 100644 --- a/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt @@ -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) } } diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt index 10df5aa..e595811 100644 --- a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt index f3ac434..7d23f5e 100644 --- a/app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index 24b1e25..30b5915 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -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 { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index 1acad29..ac3f536 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -166,6 +166,10 @@ fun SeedPhraseScreen( delay(2000) hasCopied = false } + scope.launch { + delay(30_000) + clipboardManager.setText(AnnotatedString("")) + } } ) { Icon( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt index 62e0964..34cfede 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt @@ -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) { 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() } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index e88f8df..4579c76 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -159,7 +159,9 @@ private val avatarColorsDark = // Cache для цветов аватаров data class AvatarColors(val textColor: Color, val backgroundColor: Color) -private val avatarColorCache = mutableMapOf() +private val avatarColorCache = object : LinkedHashMap(128, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 500 +} /** * Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative @@ -184,7 +186,9 @@ fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { } // Cache для инициалов -private val initialsCache = mutableMapOf() +private val initialsCache = object : LinkedHashMap(128, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 500 +} fun getInitials(name: String): String { return initialsCache.getOrPut(name) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt index 35371e8..c340a0a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 002e849..ed5fe45 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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, - 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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt index 0ee97bc..4e44c6c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt @@ -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(null) } + var cameraProvider by remember { mutableStateOf(null) } + var previewUseCase by remember { mutableStateOf(null) } + var analysisUseCase by remember { mutableStateOf(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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/BackupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/BackupScreen.kt index 33bf518..331dbee 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/BackupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/BackupScreen.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt index cda4039..0ce501b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt @@ -160,6 +160,10 @@ fun SafetyScreen( delay(2000) copiedPrivateKey = false } + scope.launch { + delay(30_000) + cm.setPrimaryClip(ClipData.newPlainText("", "")) + } } }, textColor = textColor, diff --git a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt index fde1308..8b7f22c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt @@ -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" ) diff --git a/app/src/main/java/com/rosetta/messenger/utils/RosettaDev1Log.kt b/app/src/main/java/com/rosetta/messenger/utils/RosettaDev1Log.kt new file mode 100644 index 0000000..3a81c2a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/RosettaDev1Log.kt @@ -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) + } +}