fix: Довести UI голосовых сообщений до Telegram: lock/blob, центр иконок и параллельная отправка
This commit is contained in:
@@ -978,18 +978,16 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// 🔥 Приложение стало видимым - отключаем уведомления
|
|
||||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true
|
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true
|
||||||
// 🔔 Сбрасываем все уведомления из шторки при открытии приложения
|
|
||||||
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
|
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
|
||||||
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
|
protocolGateway.setAppInForeground(true)
|
||||||
protocolGateway.reconnectNowIfNeeded("activity_onResume")
|
protocolGateway.reconnectNowIfNeeded("activity_onResume")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
// 🔥 Приложение ушло в background - включаем уведомления
|
|
||||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false
|
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false
|
||||||
|
protocolGateway.setAppInForeground(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🔔 Инициализация Firebase Cloud Messaging */
|
/** 🔔 Инициализация Firebase Cloud Messaging */
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface ProtocolGateway : ProtocolRuntimePort {
|
|||||||
fun notifyOwnProfileUpdated()
|
fun notifyOwnProfileUpdated()
|
||||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
|
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
|
||||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
|
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
|
||||||
|
fun setAppInForeground(foreground: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionCoordinator {
|
interface SessionCoordinator {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class Protocol(
|
|||||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
||||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
|
private const val BACKGROUND_HEARTBEAT_INTERVAL_MS = 30_000L
|
||||||
|
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||||
private const val HEX_PREVIEW_BYTES = 64
|
private const val HEX_PREVIEW_BYTES = 64
|
||||||
private const val TEXT_PREVIEW_CHARS = 80
|
private const val TEXT_PREVIEW_CHARS = 80
|
||||||
@@ -534,6 +536,8 @@ class Protocol(
|
|||||||
// Heartbeat
|
// Heartbeat
|
||||||
private var heartbeatJob: Job? = null
|
private var heartbeatJob: Job? = null
|
||||||
@Volatile private var heartbeatPeriodMs: Long = 0L
|
@Volatile private var heartbeatPeriodMs: Long = 0L
|
||||||
|
@Volatile private var isAppInForeground: Boolean = true
|
||||||
|
private var serverHeartbeatIntervalSec: Int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
||||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||||
@Volatile private var heartbeatOkSuppressedCount: Int = 0
|
@Volatile private var heartbeatOkSuppressedCount: Int = 0
|
||||||
|
|
||||||
@@ -615,38 +619,49 @@ class Protocol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start heartbeat to keep connection alive
|
* Start adaptive heartbeat to keep connection alive.
|
||||||
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
* Foreground: serverInterval / 2 (like desktop).
|
||||||
|
* Background: 30s to save battery.
|
||||||
*/
|
*/
|
||||||
private fun startHeartbeat(intervalSeconds: Int) {
|
private fun startHeartbeat(intervalSeconds: Int) {
|
||||||
val normalizedServerIntervalSec =
|
serverHeartbeatIntervalSec =
|
||||||
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
||||||
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
|
|
||||||
val intervalMs =
|
|
||||||
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
|
||||||
|
|
||||||
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
heartbeatPeriodMs = intervalMs
|
|
||||||
lastHeartbeatOkLogAtMs = 0L
|
lastHeartbeatOkLogAtMs = 0L
|
||||||
heartbeatOkSuppressedCount = 0
|
heartbeatOkSuppressedCount = 0
|
||||||
log(
|
|
||||||
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
|
|
||||||
"sending=${intervalMs / 1000}s, state=${_state.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
heartbeatJob = scope.launch {
|
heartbeatJob = scope.launch {
|
||||||
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
|
||||||
sendHeartbeat()
|
sendHeartbeat()
|
||||||
|
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
val intervalMs = if (isAppInForeground) {
|
||||||
|
((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
||||||
|
} else {
|
||||||
|
BACKGROUND_HEARTBEAT_INTERVAL_MS
|
||||||
|
}
|
||||||
|
heartbeatPeriodMs = intervalMs
|
||||||
delay(intervalMs)
|
delay(intervalMs)
|
||||||
sendHeartbeat()
|
sendHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fgMs = ((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
||||||
|
log(
|
||||||
|
"💓 HEARTBEAT START: server=${intervalSeconds}s, " +
|
||||||
|
"foreground=${fgMs / 1000}s, background=${BACKGROUND_HEARTBEAT_INTERVAL_MS / 1000}s, " +
|
||||||
|
"appForeground=$isAppInForeground, state=${_state.value}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify protocol about app foreground/background state.
|
||||||
|
* Adjusts heartbeat interval to save battery in background.
|
||||||
|
*/
|
||||||
|
fun setAppInForeground(foreground: Boolean) {
|
||||||
|
if (isAppInForeground == foreground) return
|
||||||
|
isAppInForeground = foreground
|
||||||
|
log("💓 App foreground=$foreground, heartbeat will adjust on next tick")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1101,22 +1116,22 @@ class Protocol(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автоматический reconnect с защитой от бесконечных попыток
|
// Автоматический reconnect с лимитом попыток
|
||||||
if (!isManuallyClosed) {
|
if (!isManuallyClosed) {
|
||||||
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
|
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
|
|
||||||
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s.
|
|
||||||
// IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset.
|
|
||||||
// Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms).
|
|
||||||
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
|
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
|
||||||
|
|
||||||
|
// После 10 попыток — останавливаемся, ждём NetworkCallback или foreground resume
|
||||||
|
if (nextAttemptNumber > MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
log("🛑 RECONNECT STOPPED: $nextAttemptNumber attempts exhausted, waiting for network change or foreground resume")
|
||||||
|
onNetworkUnavailable?.invoke()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
|
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
|
||||||
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
|
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
|
||||||
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms")
|
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber/$MAX_RECONNECT_ATTEMPTS, delay=${delayMs}ms")
|
||||||
|
|
||||||
if (nextAttemptNumber > 20) {
|
|
||||||
log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop")
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
@@ -1209,9 +1224,12 @@ class Protocol(
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, attempts=$reconnectAttempts, reason=$reason"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Reset attempt counter — network changed or user returned to app
|
||||||
|
reconnectAttempts = 0
|
||||||
|
|
||||||
if (isManuallyClosed) {
|
if (isManuallyClosed) {
|
||||||
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
|
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ class ProtocolRuntime @Inject constructor(
|
|||||||
|
|
||||||
override fun disconnect() = connectionControlApi.disconnect()
|
override fun disconnect() = connectionControlApi.disconnect()
|
||||||
|
|
||||||
|
override fun setAppInForeground(foreground: Boolean) =
|
||||||
|
connectionControlApi.setAppInForeground(foreground)
|
||||||
|
|
||||||
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
|
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
|
||||||
|
|
||||||
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
|
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ class RuntimeConnectionControlFacade(
|
|||||||
|
|
||||||
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
|
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
|
||||||
|
|
||||||
|
fun setAppInForeground(foreground: Boolean) {
|
||||||
|
runCatching { protocolInstanceManager.getOrCreateProtocol().setAppInForeground(foreground) }
|
||||||
|
}
|
||||||
|
|
||||||
fun getPrivateHashOrNull(): String? {
|
fun getPrivateHashOrNull(): String? {
|
||||||
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
|
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -37,6 +40,7 @@ data class TransportState(
|
|||||||
object TransportManager {
|
object TransportManager {
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val INITIAL_BACKOFF_MS = 1000L
|
private const val INITIAL_BACKOFF_MS = 1000L
|
||||||
|
private const val UPLOAD_ATTEMPT_TIMEOUT_MS = 45_000L
|
||||||
|
|
||||||
private var transportServer: String? = null
|
private var transportServer: String? = null
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
@@ -68,6 +72,7 @@ object TransportManager {
|
|||||||
fun setTransportServer(server: String) {
|
fun setTransportServer(server: String) {
|
||||||
val normalized = server.trim().trimEnd('/')
|
val normalized = server.trim().trimEnd('/')
|
||||||
transportServer = normalized.ifBlank { null }
|
transportServer = normalized.ifBlank { null }
|
||||||
|
RosettaDev1Log.d("net/transport-server set=${transportServer.orEmpty()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,15 +104,37 @@ object TransportManager {
|
|||||||
/**
|
/**
|
||||||
* Retry с exponential backoff: 1с, 2с, 4с
|
* Retry с exponential backoff: 1с, 2с, 4с
|
||||||
*/
|
*/
|
||||||
private suspend fun <T> withRetry(block: suspend () -> T): T {
|
private suspend fun <T> withRetry(
|
||||||
|
operation: String = "transport",
|
||||||
|
id: String = "-",
|
||||||
|
block: suspend () -> T
|
||||||
|
): T {
|
||||||
var lastException: Exception? = null
|
var lastException: Exception? = null
|
||||||
repeat(MAX_RETRIES) { attempt ->
|
repeat(MAX_RETRIES) { attempt ->
|
||||||
try {
|
try {
|
||||||
return block()
|
return block()
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
RosettaDev1Log.w(
|
||||||
|
"net/$operation cancelled id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES",
|
||||||
|
e
|
||||||
|
)
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||||
|
val shouldRetry = attempt < MAX_RETRIES - 1
|
||||||
|
if (shouldRetry) {
|
||||||
|
val backoffMs = INITIAL_BACKOFF_MS shl attempt
|
||||||
|
RosettaDev1Log.w(
|
||||||
|
"net/$operation retry id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
|
||||||
|
"backoff=${backoffMs}ms reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RosettaDev1Log.e(
|
||||||
|
"net/$operation failed id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
|
||||||
|
"reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
if (attempt < MAX_RETRIES - 1) {
|
if (attempt < MAX_RETRIES - 1) {
|
||||||
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
|
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
|
||||||
}
|
}
|
||||||
@@ -121,6 +148,7 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
fun requestTransportServer() {
|
fun requestTransportServer() {
|
||||||
val packet = PacketRequestTransport()
|
val packet = PacketRequestTransport()
|
||||||
|
RosettaDev1Log.d("net/transport-server request packet=0x0F")
|
||||||
ProtocolRuntimeAccess.get().sendPacket(packet)
|
ProtocolRuntimeAccess.get().sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +200,37 @@ object TransportManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitUploadResponse(id: String, request: Request): Response =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val call = client.newCall(request)
|
||||||
|
activeUploadCalls[id] = call
|
||||||
|
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
call.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
if (call.isCanceled()) {
|
||||||
|
cont.cancel(CancellationException("Upload cancelled"))
|
||||||
|
} else {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
if (cont.isCancelled) {
|
||||||
|
response.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseContentRangeTotal(value: String?): Long? {
|
private fun parseContentRangeTotal(value: String?): Long? {
|
||||||
if (value.isNullOrBlank()) return null
|
if (value.isNullOrBlank()) return null
|
||||||
// Example: "bytes 100-999/12345"
|
// Example: "bytes 100-999/12345"
|
||||||
@@ -188,13 +247,16 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer()
|
val server = getActiveServer()
|
||||||
|
RosettaDev1Log.i(
|
||||||
|
"net/upload start id=${id.take(12)} server=$server bytes=${content.length}"
|
||||||
|
)
|
||||||
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||||
|
|
||||||
// Добавляем в список загрузок
|
// Добавляем в список загрузок
|
||||||
_uploading.value = _uploading.value + TransportState(id, 0)
|
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withRetry {
|
withRetry(operation = "upload", id = id) {
|
||||||
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
||||||
val totalSize = contentBytes.size.toLong()
|
val totalSize = contentBytes.size.toLong()
|
||||||
|
|
||||||
@@ -206,6 +268,7 @@ object TransportManager {
|
|||||||
val source = okio.Buffer().write(contentBytes)
|
val source = okio.Buffer().write(contentBytes)
|
||||||
var uploaded = 0L
|
var uploaded = 0L
|
||||||
val bufferSize = 8 * 1024L
|
val bufferSize = 8 * 1024L
|
||||||
|
var lastProgressUpdateMs = 0L
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = source.read(sink.buffer, bufferSize)
|
val read = source.read(sink.buffer, bufferSize)
|
||||||
@@ -214,9 +277,14 @@ object TransportManager {
|
|||||||
uploaded += read
|
uploaded += read
|
||||||
sink.flush()
|
sink.flush()
|
||||||
|
|
||||||
val progress = ((uploaded * 100) / totalSize).toInt()
|
val now = System.currentTimeMillis()
|
||||||
_uploading.value = _uploading.value.map {
|
val isLast = uploaded >= totalSize
|
||||||
if (it.id == id) it.copy(progress = progress) else it
|
if (isLast || now - lastProgressUpdateMs >= 200) {
|
||||||
|
lastProgressUpdateMs = now
|
||||||
|
val progress = ((uploaded * 100) / totalSize).toInt()
|
||||||
|
_uploading.value = _uploading.value.map {
|
||||||
|
if (it.id == id) it.copy(progress = progress) else it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,44 +299,38 @@ object TransportManager {
|
|||||||
.url("$server/u")
|
.url("$server/u")
|
||||||
.post(requestBody)
|
.post(requestBody)
|
||||||
.build()
|
.build()
|
||||||
|
val response =
|
||||||
val response = suspendCancellableCoroutine<Response> { cont ->
|
try {
|
||||||
val call = client.newCall(request)
|
withTimeout(UPLOAD_ATTEMPT_TIMEOUT_MS) {
|
||||||
activeUploadCalls[id] = call
|
awaitUploadResponse(id, request)
|
||||||
|
}
|
||||||
cont.invokeOnCancellation {
|
} catch (timeout: CancellationException) {
|
||||||
activeUploadCalls.remove(id, call)
|
if (timeout is kotlinx.coroutines.TimeoutCancellationException) {
|
||||||
call.cancel()
|
activeUploadCalls.remove(id)?.cancel()
|
||||||
|
RosettaDev1Log.w(
|
||||||
|
"net/upload attempt-timeout id=${id.take(12)} timeoutMs=$UPLOAD_ATTEMPT_TIMEOUT_MS"
|
||||||
|
)
|
||||||
|
throw SocketTimeoutException(
|
||||||
|
"Upload timeout after ${UPLOAD_ATTEMPT_TIMEOUT_MS}ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
call.enqueue(object : Callback {
|
val tag =
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
response.use { uploadResponse ->
|
||||||
activeUploadCalls.remove(id, call)
|
if (!uploadResponse.isSuccessful) {
|
||||||
if (call.isCanceled()) {
|
val errorBody = uploadResponse.body?.string()?.take(240).orEmpty()
|
||||||
cont.cancel(CancellationException("Upload cancelled"))
|
RosettaDev1Log.e(
|
||||||
} else {
|
"net/upload http-fail id=${id.take(12)} code=${uploadResponse.code} body=$errorBody"
|
||||||
cont.resumeWithException(e)
|
)
|
||||||
}
|
throw IOException("Upload failed: ${uploadResponse.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
val responseBody = uploadResponse.body?.string()
|
||||||
activeUploadCalls.remove(id, call)
|
?: throw IOException("Empty response")
|
||||||
if (cont.isCancelled) {
|
org.json.JSONObject(responseBody).getString("t")
|
||||||
response.close()
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
cont.resume(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IOException("Upload failed: ${response.code}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val responseBody = response.body?.string()
|
|
||||||
?: throw IOException("Empty response")
|
|
||||||
val tag = org.json.JSONObject(responseBody).getString("t")
|
|
||||||
|
|
||||||
// Обновляем прогресс до 100%
|
// Обновляем прогресс до 100%
|
||||||
_uploading.value = _uploading.value.map {
|
_uploading.value = _uploading.value.map {
|
||||||
@@ -276,16 +338,22 @@ object TransportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||||
|
RosettaDev1Log.i("net/upload success id=${id.take(12)} tag=${tag.take(16)}")
|
||||||
|
|
||||||
tag
|
tag
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||||
|
RosettaDev1Log.w("net/upload cancelled id=${id.take(12)}", e)
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolRuntimeAccess.get().addLog(
|
ProtocolRuntimeAccess.get().addLog(
|
||||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
)
|
)
|
||||||
|
RosettaDev1Log.e(
|
||||||
|
"net/upload failed id=${id.take(12)} reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
|
||||||
|
e
|
||||||
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
activeUploadCalls.remove(id)?.cancel()
|
activeUploadCalls.remove(id)?.cancel()
|
||||||
@@ -315,7 +383,7 @@ object TransportManager {
|
|||||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withRetry {
|
withRetry(operation = "download", id = id) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$server/d/$tag")
|
.url("$server/d/$tag")
|
||||||
.get()
|
.get()
|
||||||
@@ -464,7 +532,7 @@ object TransportManager {
|
|||||||
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
|
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withRetry {
|
withRetry(operation = "download-raw-resume", id = id) {
|
||||||
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
|
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
|
||||||
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
|
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
|
||||||
.coerceAtMost(existingBytes)
|
.coerceAtMost(existingBytes)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.network.connection
|
|||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.network.DeliveryStatus
|
import com.rosetta.messenger.network.DeliveryStatus
|
||||||
import com.rosetta.messenger.network.PacketMessage
|
import com.rosetta.messenger.network.PacketMessage
|
||||||
|
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
class OutgoingMessagePipelineService(
|
class OutgoingMessagePipelineService(
|
||||||
@@ -22,15 +23,21 @@ class OutgoingMessagePipelineService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun sendWithRetry(packet: PacketMessage) {
|
fun sendWithRetry(packet: PacketMessage) {
|
||||||
|
RosettaDev1Log.d(
|
||||||
|
"net/pipeline sendWithRetry msg=${packet.messageId.take(8)} " +
|
||||||
|
"to=${packet.toPublicKey.take(12)} from=${packet.fromPublicKey.take(12)}"
|
||||||
|
)
|
||||||
sendPacket(packet)
|
sendPacket(packet)
|
||||||
retryQueueService.register(packet)
|
retryQueueService.register(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveOutgoingRetry(messageId: String) {
|
fun resolveOutgoingRetry(messageId: String) {
|
||||||
|
RosettaDev1Log.d("net/pipeline resolveRetry msg=${messageId.take(8)}")
|
||||||
retryQueueService.resolve(messageId)
|
retryQueueService.resolve(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearRetryQueue() {
|
fun clearRetryQueue() {
|
||||||
|
RosettaDev1Log.d("net/pipeline clearRetryQueue")
|
||||||
retryQueueService.clear()
|
retryQueueService.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +50,7 @@ class OutgoingMessagePipelineService(
|
|||||||
packet.fromPublicKey
|
packet.fromPublicKey
|
||||||
}
|
}
|
||||||
val dialogKey = repository.getDialogKey(opponentKey)
|
val dialogKey = repository.getDialogKey(opponentKey)
|
||||||
|
RosettaDev1Log.w("net/pipeline markError msg=${messageId.take(8)} dialog=${dialogKey.take(16)}")
|
||||||
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ internal class ProtocolDebugLogService(
|
|||||||
|
|
||||||
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
||||||
if (uiLogsEnabled) return true
|
if (uiLogsEnabled) return true
|
||||||
|
if (message.contains("rosettadev1", ignoreCase = true)) return true
|
||||||
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
||||||
if (message.contains("STATE CHANGE")) return true
|
if (message.contains("STATE CHANGE")) return true
|
||||||
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.network.connection
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
import com.rosetta.messenger.network.PacketMessage
|
import com.rosetta.messenger.network.PacketMessage
|
||||||
|
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -31,6 +32,7 @@ class RetryQueueService(
|
|||||||
|
|
||||||
fun register(packet: PacketMessage) {
|
fun register(packet: PacketMessage) {
|
||||||
val messageId = packet.messageId
|
val messageId = packet.messageId
|
||||||
|
RosettaDev1Log.d("net/retry register msg=${messageId.take(8)}")
|
||||||
pendingOutgoingRetryJobs[messageId]?.cancel()
|
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||||
pendingOutgoingPackets[messageId] = packet
|
pendingOutgoingPackets[messageId] = packet
|
||||||
pendingOutgoingAttempts[messageId] = 0
|
pendingOutgoingAttempts[messageId] = 0
|
||||||
@@ -38,6 +40,7 @@ class RetryQueueService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resolve(messageId: String) {
|
fun resolve(messageId: String) {
|
||||||
|
RosettaDev1Log.d("net/retry resolve msg=${messageId.take(8)}")
|
||||||
pendingOutgoingRetryJobs[messageId]?.cancel()
|
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||||
pendingOutgoingRetryJobs.remove(messageId)
|
pendingOutgoingRetryJobs.remove(messageId)
|
||||||
pendingOutgoingPackets.remove(messageId)
|
pendingOutgoingPackets.remove(messageId)
|
||||||
@@ -45,6 +48,7 @@ class RetryQueueService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
RosettaDev1Log.d("net/retry clear size=${pendingOutgoingRetryJobs.size}")
|
||||||
pendingOutgoingRetryJobs.values.forEach { it.cancel() }
|
pendingOutgoingRetryJobs.values.forEach { it.cancel() }
|
||||||
pendingOutgoingRetryJobs.clear()
|
pendingOutgoingRetryJobs.clear()
|
||||||
pendingOutgoingPackets.clear()
|
pendingOutgoingPackets.clear()
|
||||||
@@ -63,6 +67,9 @@ class RetryQueueService(
|
|||||||
val nowMs = System.currentTimeMillis()
|
val nowMs = System.currentTimeMillis()
|
||||||
val ageMs = nowMs - packet.timestamp
|
val ageMs = nowMs - packet.timestamp
|
||||||
if (ageMs >= maxLifetimeMs) {
|
if (ageMs >= maxLifetimeMs) {
|
||||||
|
RosettaDev1Log.w(
|
||||||
|
"net/retry expired msg=${messageId.take(8)} age=${ageMs}ms"
|
||||||
|
)
|
||||||
addLog(
|
addLog(
|
||||||
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
|
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
|
||||||
)
|
)
|
||||||
@@ -72,6 +79,9 @@ class RetryQueueService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attempts >= maxRetryAttempts) {
|
if (attempts >= maxRetryAttempts) {
|
||||||
|
RosettaDev1Log.w(
|
||||||
|
"net/retry exhausted msg=${messageId.take(8)} attempts=$attempts"
|
||||||
|
)
|
||||||
addLog(
|
addLog(
|
||||||
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
|
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
|
||||||
)
|
)
|
||||||
@@ -81,6 +91,7 @@ class RetryQueueService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
|
RosettaDev1Log.w("net/retry deferred-not-auth msg=${messageId.take(8)}")
|
||||||
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
|
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
|
||||||
resolve(messageId)
|
resolve(messageId)
|
||||||
return@launch
|
return@launch
|
||||||
@@ -88,6 +99,9 @@ class RetryQueueService(
|
|||||||
|
|
||||||
val nextAttempt = attempts + 1
|
val nextAttempt = attempts + 1
|
||||||
pendingOutgoingAttempts[messageId] = nextAttempt
|
pendingOutgoingAttempts[messageId] = nextAttempt
|
||||||
|
RosettaDev1Log.i(
|
||||||
|
"net/retry resend msg=${messageId.take(8)} attempt=$nextAttempt/$maxRetryAttempts"
|
||||||
|
)
|
||||||
addLog("🔄 Retrying message ${messageId.take(8)}, attempt $nextAttempt")
|
addLog("🔄 Retrying message ${messageId.take(8)}, attempt $nextAttempt")
|
||||||
sendPacket(packet)
|
sendPacket(packet)
|
||||||
schedule(messageId)
|
schedule(messageId)
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -111,10 +113,31 @@ fun DeviceConfirmScreen(
|
|||||||
val onExitState by rememberUpdatedState(onExit)
|
val onExitState by rememberUpdatedState(onExit)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||||
|
var isResumed by remember(lifecycleOwner) {
|
||||||
|
mutableStateOf(
|
||||||
|
lifecycleOwner.lifecycle.currentState.isAtLeast(
|
||||||
|
androidx.lifecycle.Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = androidx.lifecycle.LifecycleEventObserver { _, _ ->
|
||||||
|
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(
|
||||||
|
androidx.lifecycle.Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
|
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
|
||||||
val progress by animateLottieCompositionAsState(
|
val progress by animateLottieCompositionAsState(
|
||||||
composition = composition,
|
composition = composition,
|
||||||
iterations = LottieConstants.IterateForever
|
iterations = LottieConstants.IterateForever,
|
||||||
|
isPlaying = isResumed
|
||||||
)
|
)
|
||||||
|
|
||||||
val localDeviceName = remember {
|
val localDeviceName = remember {
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
|
|||||||
delay(2000)
|
delay(2000)
|
||||||
hasCopied = false
|
hasCopied = false
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
delay(30_000)
|
||||||
|
clipboardManager.setText(AnnotatedString(""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.rosetta.messenger.ui.chats.models.MessageStatus
|
|||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.rosetta.messenger.utils.MediaUtils
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
|
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -134,11 +135,13 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
|
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
|
||||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||||
|
RosettaDev1Log.w("voice-send slot busy: request dropped")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
|
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
|
||||||
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
|
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
|
||||||
|
RosettaDev1Log.w("voice-send payload invalid: empty normalized voice")
|
||||||
chatViewModel.releaseSendSlot()
|
chatViewModel.releaseSendSlot()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,6 +149,13 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
val attachmentId = "voice_$timestamp"
|
val attachmentId = "voice_$timestamp"
|
||||||
|
val senderShort = sendContext.sender.take(12)
|
||||||
|
val recipientShort = sendContext.recipient.take(12)
|
||||||
|
RosettaDev1Log.i(
|
||||||
|
"voice-send start msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||||
|
"from=$senderShort to=$recipientShort dur=${voicePayload.durationSec}s " +
|
||||||
|
"waves=${voicePayload.normalizedWaves.size} blobLen=${voicePayload.normalizedVoiceHex.length}"
|
||||||
|
)
|
||||||
|
|
||||||
chatViewModel.addOutgoingMessageOptimistic(
|
chatViewModel.addOutgoingMessageOptimistic(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
@@ -178,6 +188,10 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||||
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||||
|
RosettaDev1Log.d(
|
||||||
|
"voice-send upload begin msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||||
|
"saved=$isSavedMessages"
|
||||||
|
)
|
||||||
val uploadResult =
|
val uploadResult =
|
||||||
chatViewModel.encryptAndUploadAttachment(
|
chatViewModel.encryptAndUploadAttachment(
|
||||||
EncryptAndUploadAttachmentCommand(
|
EncryptAndUploadAttachmentCommand(
|
||||||
@@ -187,6 +201,10 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
isSavedMessages = isSavedMessages
|
isSavedMessages = isSavedMessages
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
RosettaDev1Log.d(
|
||||||
|
"voice-send upload done msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||||
|
"tag=${uploadResult.transportTag.take(16)} server=${uploadResult.transportServer}"
|
||||||
|
)
|
||||||
|
|
||||||
val voiceAttachment =
|
val voiceAttachment =
|
||||||
MessageAttachment(
|
MessageAttachment(
|
||||||
@@ -212,6 +230,10 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
isSavedMessages = isSavedMessages
|
isSavedMessages = isSavedMessages
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
RosettaDev1Log.i(
|
||||||
|
"voice-send packet dispatched msg=${messageId.take(8)} " +
|
||||||
|
"saved=$isSavedMessages attachments=1"
|
||||||
|
)
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
AttachmentFileManager.saveAttachment(
|
AttachmentFileManager.saveAttachment(
|
||||||
@@ -275,7 +297,13 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
accountPrivateKey = sendContext.privateKey,
|
accountPrivateKey = sendContext.privateKey,
|
||||||
opponentPublicKey = sendContext.recipient
|
opponentPublicKey = sendContext.recipient
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
RosettaDev1Log.i("voice-send done msg=${messageId.take(8)} status=queued")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RosettaDev1Log.e(
|
||||||
|
"voice-send failed msg=${messageId.take(8)} " +
|
||||||
|
"from=${sendContext.sender.take(12)} to=${sendContext.recipient.take(12)}",
|
||||||
|
e
|
||||||
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
@@ -288,6 +316,7 @@ class VoiceRecordingViewModel internal constructor(
|
|||||||
opponentPublicKey = sendContext.recipient
|
opponentPublicKey = sendContext.recipient
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
|
RosettaDev1Log.d("voice-send slot released msg=${messageId.take(8)}")
|
||||||
chatViewModel.releaseSendSlot()
|
chatViewModel.releaseSendSlot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ private val avatarColorsDark =
|
|||||||
// Cache для цветов аватаров
|
// Cache для цветов аватаров
|
||||||
data class AvatarColors(val textColor: Color, val backgroundColor: Color)
|
data class AvatarColors(val textColor: Color, val backgroundColor: Color)
|
||||||
|
|
||||||
private val avatarColorCache = mutableMapOf<String, AvatarColors>()
|
private val avatarColorCache = object : LinkedHashMap<String, AvatarColors>(128, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AvatarColors>?) = size > 500
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative
|
* Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative
|
||||||
@@ -184,7 +186,9 @@ fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache для инициалов
|
// Cache для инициалов
|
||||||
private val initialsCache = mutableMapOf<String, String>()
|
private val initialsCache = object : LinkedHashMap<String, String>(128, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?) = size > 500
|
||||||
|
}
|
||||||
|
|
||||||
fun getInitials(name: String): String {
|
fun getInitials(name: String): String {
|
||||||
return initialsCache.getOrPut(name) {
|
return initialsCache.getOrPut(name) {
|
||||||
|
|||||||
@@ -94,18 +94,6 @@ internal class MessagesCoordinator(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chatViewModel.isSendSlotBusy()) {
|
|
||||||
logSendBlocked(
|
|
||||||
reason = "is_sending",
|
|
||||||
textLength = text.length,
|
|
||||||
hasReply = replyMsgsToSend.isNotEmpty(),
|
|
||||||
recipient = recipient,
|
|
||||||
sender = sender,
|
|
||||||
hasPrivateKey = true
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val command =
|
val command =
|
||||||
SendCommand(
|
SendCommand(
|
||||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||||
@@ -130,18 +118,6 @@ internal class MessagesCoordinator(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
|
||||||
logSendBlocked(
|
|
||||||
reason = "is_sending",
|
|
||||||
textLength = command.text.length,
|
|
||||||
hasReply = command.replyMessages.isNotEmpty(),
|
|
||||||
recipient = command.recipientPublicKey,
|
|
||||||
sender = command.senderPublicKey,
|
|
||||||
hasPrivateKey = true
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageId = command.messageId
|
val messageId = command.messageId
|
||||||
val timestamp = command.timestamp
|
val timestamp = command.timestamp
|
||||||
val fallbackName = chatViewModel.replyFallbackName()
|
val fallbackName = chatViewModel.replyFallbackName()
|
||||||
@@ -443,7 +419,6 @@ internal class MessagesCoordinator(
|
|||||||
opponentPublicKey = command.recipientPublicKey
|
opponentPublicKey = command.recipientPublicKey
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
chatViewModel.releaseSendSlot()
|
|
||||||
triggerPendingTextSendIfReady("send_finished")
|
triggerPendingTextSendIfReady("send_finished")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,7 +496,7 @@ internal class MessagesCoordinator(
|
|||||||
|
|
||||||
val recipientReady = chatViewModel.currentRecipientForSend() != null
|
val recipientReady = chatViewModel.currentRecipientForSend() != null
|
||||||
val keysReady = chatViewModel.hasRuntimeKeysForSend()
|
val keysReady = chatViewModel.hasRuntimeKeysForSend()
|
||||||
if (!recipientReady || !keysReady || chatViewModel.isSendSlotBusy()) return
|
if (!recipientReady || !keysReady) return
|
||||||
|
|
||||||
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
|
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
|
||||||
clearPendingRecovery(cancelJob = true)
|
clearPendingRecovery(cancelJob = true)
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import com.airbnb.lottie.compose.LottieCompositionSpec
|
|||||||
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
|
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
|
||||||
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
|
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
|
||||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
@@ -768,14 +765,20 @@ private fun LockIcon(
|
|||||||
|
|
||||||
// ── Pause/Resume transform in locked mode ──
|
// ── Pause/Resume transform in locked mode ──
|
||||||
if (pauseTransform > 0.01f) {
|
if (pauseTransform > 0.01f) {
|
||||||
|
val pausePlayCenterY =
|
||||||
|
if (lockedOrPaused) {
|
||||||
|
pillTop + pillH * 0.5f
|
||||||
|
} else {
|
||||||
|
bodyCy
|
||||||
|
}
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
val triW = 10f * dp1
|
val triW = 10f * dp1
|
||||||
val triH = 12f * dp1
|
val triH = 12f * dp1
|
||||||
val left = bodyCx - triW * 0.35f
|
val left = bodyCx - triW / 2f
|
||||||
val top = bodyCy - triH / 2f
|
val top = pausePlayCenterY - triH / 2f
|
||||||
val playPath = Path().apply {
|
val playPath = Path().apply {
|
||||||
moveTo(left, top)
|
moveTo(left, top)
|
||||||
lineTo(left + triW, bodyCy)
|
lineTo(left + triW, pausePlayCenterY)
|
||||||
lineTo(left, top + triH)
|
lineTo(left, top + triH)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -790,13 +793,13 @@ private fun LockIcon(
|
|||||||
val barRadius = 1.5f * dp1
|
val barRadius = 1.5f * dp1
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = iconColor.copy(alpha = pauseTransform),
|
color = iconColor.copy(alpha = pauseTransform),
|
||||||
topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f),
|
topLeft = Offset(bodyCx - gap - barW, pausePlayCenterY - barH / 2f),
|
||||||
size = androidx.compose.ui.geometry.Size(barW, barH),
|
size = androidx.compose.ui.geometry.Size(barW, barH),
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
|
||||||
)
|
)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = iconColor.copy(alpha = pauseTransform),
|
color = iconColor.copy(alpha = pauseTransform),
|
||||||
topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f),
|
topLeft = Offset(bodyCx + gap, pausePlayCenterY - barH / 2f),
|
||||||
size = androidx.compose.ui.geometry.Size(barW, barH),
|
size = androidx.compose.ui.geometry.Size(barW, barH),
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius)
|
||||||
)
|
)
|
||||||
@@ -951,130 +954,6 @@ private fun LockTooltip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun VoiceWaveformBar(
|
|
||||||
waves: List<Float>,
|
|
||||||
isDarkTheme: Boolean,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val barColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
|
|
||||||
val barWidthDp = 2.dp
|
|
||||||
val barGapDp = 1.dp
|
|
||||||
val minBarHeightDp = 2.dp
|
|
||||||
val maxBarHeightDp = 20.dp
|
|
||||||
|
|
||||||
Canvas(modifier = modifier.height(maxBarHeightDp)) {
|
|
||||||
val barWidthPx = barWidthDp.toPx()
|
|
||||||
val barGapPx = barGapDp.toPx()
|
|
||||||
val minH = minBarHeightDp.toPx()
|
|
||||||
val maxH = maxBarHeightDp.toPx()
|
|
||||||
val totalBarWidth = barWidthPx + barGapPx
|
|
||||||
val maxBars = (size.width / totalBarWidth).toInt().coerceAtLeast(1)
|
|
||||||
val displayWaves = if (waves.size > maxBars) waves.takeLast(maxBars) else waves
|
|
||||||
val cy = size.height / 2f
|
|
||||||
|
|
||||||
displayWaves.forEachIndexed { index, level ->
|
|
||||||
val barH = minH + (maxH - minH) * level.coerceIn(0f, 1f)
|
|
||||||
val x = (maxBars - displayWaves.size + index) * totalBarWidth
|
|
||||||
drawRoundRect(
|
|
||||||
color = barColor,
|
|
||||||
topLeft = Offset(x, cy - barH / 2f),
|
|
||||||
size = androidx.compose.ui.geometry.Size(barWidthPx, barH),
|
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidthPx / 2f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Telegram-exact locked recording controls.
|
|
||||||
*
|
|
||||||
* Layout: [CANCEL text-button] [⏸/▶ circle button]
|
|
||||||
*
|
|
||||||
* - CANCEL = blue text (15sp bold, uppercase), clickable — cancels recording
|
|
||||||
* - ⏸ = small circle button (36dp), toggles pause/resume
|
|
||||||
* - No separate delete icon — CANCEL IS delete
|
|
||||||
*
|
|
||||||
* Reference: ChatActivityEnterView recordedAudioPanel + SlideTextView cancelToProgress
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun RecordLockedControls(
|
|
||||||
isPaused: Boolean,
|
|
||||||
isDarkTheme: Boolean,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onTogglePause: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val cancelColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
|
|
||||||
val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.15f) else Color(0xFF2D9CFF).copy(alpha = 0.1f)
|
|
||||||
val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
// CANCEL text button — Telegram: blue bold uppercase
|
|
||||||
Text(
|
|
||||||
text = "CANCEL",
|
|
||||||
color = cancelColor,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null
|
|
||||||
) { onDelete() }
|
|
||||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pause/Resume button — circle with icon
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(pauseBgColor)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null
|
|
||||||
) { onTogglePause() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (isPaused) {
|
|
||||||
// Play triangle
|
|
||||||
Canvas(modifier = Modifier.size(14.dp)) {
|
|
||||||
val path = Path().apply {
|
|
||||||
moveTo(size.width * 0.2f, 0f)
|
|
||||||
lineTo(size.width, size.height / 2f)
|
|
||||||
lineTo(size.width * 0.2f, size.height)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
drawPath(path, color = pauseIconColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Pause bars
|
|
||||||
Canvas(modifier = Modifier.size(14.dp)) {
|
|
||||||
val barW = size.width * 0.22f
|
|
||||||
val gap = size.width * 0.14f
|
|
||||||
drawRoundRect(
|
|
||||||
color = pauseIconColor,
|
|
||||||
topLeft = Offset(size.width / 2f - gap - barW, size.height * 0.1f),
|
|
||||||
size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
|
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
|
|
||||||
)
|
|
||||||
drawRoundRect(
|
|
||||||
color = pauseIconColor,
|
|
||||||
topLeft = Offset(size.width / 2f + gap, size.height * 0.1f),
|
|
||||||
size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f),
|
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message input bar and related components
|
* Message input bar and related components
|
||||||
* Extracted from ChatDetailScreen.kt for better organization
|
* Extracted from ChatDetailScreen.kt for better organization
|
||||||
@@ -2588,8 +2467,8 @@ fun MessageInputBar(
|
|||||||
|
|
||||||
// ── Telegram-exact recording layout ──
|
// ── Telegram-exact recording layout ──
|
||||||
// RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob]
|
// RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob]
|
||||||
// LOCKED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Pause above
|
// LOCKED: [Cancel] [Timer] ... [Circle=Send] + Lock→Pause above
|
||||||
// PAUSED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Play above
|
// PAUSED: [Cancel] [Timer] ... [Circle=Send] + Lock→Play above
|
||||||
// Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact)
|
// Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2679,44 +2558,51 @@ fun MessageInputBar(
|
|||||||
label = "record_panel_mode"
|
label = "record_panel_mode"
|
||||||
) { locked ->
|
) { locked ->
|
||||||
if (locked) {
|
if (locked) {
|
||||||
// ── LOCKED/PAUSED panel (Telegram: recordedAudioPanel) ──
|
// ── LOCKED/PAUSED panel (Telegram-like) ──
|
||||||
// [Delete 44dp] [Waveform fills rest]
|
// No live waveform in input row: show Cancel + Timer.
|
||||||
Row(
|
val lockedTimerMs =
|
||||||
|
if (isVoicePaused && voicePausedElapsedMs > 0L) {
|
||||||
|
voicePausedElapsedMs
|
||||||
|
} else {
|
||||||
|
voiceElapsedMs
|
||||||
|
}
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
.background(recordingPanelColor),
|
.background(recordingPanelColor)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
// Delete button — Telegram-style trash action
|
Text(
|
||||||
|
text = formatVoiceRecordTimer(lockedTimerMs),
|
||||||
|
color = recordingTextColor,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(start = 14.dp)
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(recordingActionButtonBaseSize)
|
.align(Alignment.Center)
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 14.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState")
|
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap CANCEL (locked/paused) mode=$recordMode state=$recordUiState")
|
||||||
stopVoiceRecording(send = false)
|
stopVoiceRecording(send = false)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Text(
|
||||||
imageVector = Icons.Default.Delete,
|
text = "CANCEL",
|
||||||
contentDescription = "Delete recording",
|
fontSize = 15.sp,
|
||||||
tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D),
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.size(20.dp)
|
color = PrimaryBlue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform — Telegram: 32dp height, fills remaining width
|
|
||||||
VoiceWaveformBar(
|
|
||||||
waves = voiceWaves,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── RECORDING panel ──
|
// ── RECORDING panel ──
|
||||||
@@ -2943,11 +2829,35 @@ fun MessageInputBar(
|
|||||||
.zIndex(5f),
|
.zIndex(5f),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Blob: only during RECORDING
|
// Blob: visible in RECORDING + LOCKED (and during lock transition)
|
||||||
if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) {
|
// so locked state keeps the same "alive" blue bubbles behind the circle.
|
||||||
|
val showVoiceBlob =
|
||||||
|
(
|
||||||
|
recordUiState == RecordUiState.RECORDING ||
|
||||||
|
recordUiState == RecordUiState.LOCKED ||
|
||||||
|
recordUiState == RecordUiState.PAUSED ||
|
||||||
|
isLockTransitioning
|
||||||
|
) && !isVoiceCancelAnimating
|
||||||
|
val blobInLockedMode =
|
||||||
|
recordUiState == RecordUiState.LOCKED ||
|
||||||
|
recordUiState == RecordUiState.PAUSED ||
|
||||||
|
isLockTransitioning
|
||||||
|
val blobSlideProgress =
|
||||||
|
if (blobInLockedMode) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
slideToCancelProgress
|
||||||
|
}
|
||||||
|
val blobVoiceLevel =
|
||||||
|
if (blobInLockedMode) {
|
||||||
|
maxOf(voiceLevel, 0.26f)
|
||||||
|
} else {
|
||||||
|
voiceLevel
|
||||||
|
}
|
||||||
|
if (showVoiceBlob) {
|
||||||
VoiceButtonBlob(
|
VoiceButtonBlob(
|
||||||
voiceLevel = voiceLevel,
|
voiceLevel = blobVoiceLevel,
|
||||||
slideToCancelProgress = slideToCancelProgress,
|
slideToCancelProgress = blobSlideProgress,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(recordingActionButtonBaseSize)
|
.size(recordingActionButtonBaseSize)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.google.mlkit.vision.common.InputImage
|
|||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.X
|
import compose.icons.tablericons.X
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
data class QrScanResult(
|
data class QrScanResult(
|
||||||
val type: QrResultType,
|
val type: QrResultType,
|
||||||
@@ -68,6 +69,9 @@ fun QrScannerScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
|
||||||
|
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||||
|
val barcodeScanner = remember { BarcodeScanning.getClient() }
|
||||||
|
|
||||||
var hasCameraPermission by remember {
|
var hasCameraPermission by remember {
|
||||||
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||||
@@ -80,59 +84,141 @@ fun QrScannerScreen(
|
|||||||
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
|
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
|
|
||||||
var scannedOnce by remember { mutableStateOf(false) }
|
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||||
|
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
|
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
|
||||||
|
var analysisUseCase by remember { mutableStateOf<ImageAnalysis?>(null) }
|
||||||
|
val scannedOnce = remember { AtomicBoolean(false) }
|
||||||
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun requestClose() {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
runCatching { analysisUseCase?.clearAnalyzer() }
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatchResult(result: QrScanResult) {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
runCatching { analysisUseCase?.clearAnalyzer() }
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
runCatching { analysisUseCase?.clearAnalyzer() }
|
||||||
|
runCatching {
|
||||||
|
val provider = cameraProvider
|
||||||
|
val preview = previewUseCase
|
||||||
|
val analyzer = analysisUseCase
|
||||||
|
if (provider != null && preview != null && analyzer != null) {
|
||||||
|
provider.unbind(preview, analyzer)
|
||||||
|
} else {
|
||||||
|
provider?.unbindAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { barcodeScanner.close() }
|
||||||
|
runCatching { cameraExecutor.shutdownNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(hasCameraPermission, lifecycleOwner, previewView, isClosing) {
|
||||||
|
val localPreviewView = previewView
|
||||||
|
if (!hasCameraPermission || localPreviewView == null || isClosing) {
|
||||||
|
onDispose {}
|
||||||
|
} else {
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
val listener = Runnable {
|
||||||
|
val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
|
||||||
|
cameraProvider = provider
|
||||||
|
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.setSurfaceProvider(localPreviewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
val analyzer = ImageAnalysis.Builder()
|
||||||
|
.setTargetResolution(Size(1280, 720))
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
analyzer.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||||
|
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||||
|
val mediaImage = imageProxy.image
|
||||||
|
if (mediaImage == null || scannedOnce.get() || isClosing) {
|
||||||
|
imageProxy.close()
|
||||||
|
return@setAnalyzer
|
||||||
|
}
|
||||||
|
|
||||||
|
val image =
|
||||||
|
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||||
|
|
||||||
|
barcodeScanner.process(image)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
if (scannedOnce.get() || isClosing) return@addOnSuccessListener
|
||||||
|
val parsedResult =
|
||||||
|
barcodes.firstNotNullOfOrNull { barcode ->
|
||||||
|
if (barcode.valueType != Barcode.TYPE_TEXT &&
|
||||||
|
barcode.valueType != Barcode.TYPE_URL
|
||||||
|
) {
|
||||||
|
return@firstNotNullOfOrNull null
|
||||||
|
}
|
||||||
|
val raw = barcode.rawValue ?: return@firstNotNullOfOrNull null
|
||||||
|
val parsed = parseQrContent(raw)
|
||||||
|
parsed.takeIf { it.type != QrResultType.UNKNOWN }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedResult != null && scannedOnce.compareAndSet(false, true)) {
|
||||||
|
dispatchResult(parsedResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnCompleteListener { imageProxy.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val oldPreview = previewUseCase
|
||||||
|
val oldAnalyzer = analysisUseCase
|
||||||
|
if (oldPreview != null && oldAnalyzer != null) {
|
||||||
|
provider.unbind(oldPreview, oldAnalyzer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewUseCase = preview
|
||||||
|
analysisUseCase = analyzer
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
provider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
analyzer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraProviderFuture.addListener(listener, mainExecutor)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
runCatching { analysisUseCase?.clearAnalyzer() }
|
||||||
|
runCatching {
|
||||||
|
val provider = cameraProvider
|
||||||
|
val preview = previewUseCase
|
||||||
|
val analyzer = analysisUseCase
|
||||||
|
if (provider != null && preview != null && analyzer != null) {
|
||||||
|
provider.unbind(preview, analyzer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
val previewView = PreviewView(ctx)
|
PreviewView(ctx).also { created ->
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
previewView = created
|
||||||
cameraProviderFuture.addListener({
|
}
|
||||||
val cameraProvider = cameraProviderFuture.get()
|
|
||||||
val preview = Preview.Builder().build().also {
|
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
||||||
}
|
|
||||||
val analyzer = ImageAnalysis.Builder()
|
|
||||||
.setTargetResolution(Size(1280, 720))
|
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val scanner = BarcodeScanning.getClient()
|
|
||||||
analyzer.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->
|
|
||||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
|
||||||
val mediaImage = imageProxy.image
|
|
||||||
if (mediaImage != null && !scannedOnce) {
|
|
||||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
|
||||||
scanner.process(image)
|
|
||||||
.addOnSuccessListener { barcodes ->
|
|
||||||
for (barcode in barcodes) {
|
|
||||||
if (barcode.valueType == Barcode.TYPE_TEXT || barcode.valueType == Barcode.TYPE_URL) {
|
|
||||||
val raw = barcode.rawValue ?: continue
|
|
||||||
val result = parseQrContent(raw)
|
|
||||||
if (result.type != QrResultType.UNKNOWN && !scannedOnce) {
|
|
||||||
scannedOnce = true
|
|
||||||
onResult(result)
|
|
||||||
return@addOnSuccessListener
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.addOnCompleteListener { imageProxy.close() }
|
|
||||||
} else {
|
|
||||||
imageProxy.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
cameraProvider.unbindAll()
|
|
||||||
cameraProvider.bindToLifecycle(
|
|
||||||
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analyzer
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}, ContextCompat.getMainExecutor(ctx))
|
|
||||||
previewView
|
|
||||||
},
|
},
|
||||||
|
update = { updated -> previewView = updated },
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -155,7 +241,7 @@ fun QrScannerScreen(
|
|||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = ::requestClose) {
|
||||||
Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp))
|
Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ fun BackupScreen(
|
|||||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
val clip = android.content.ClipData.newPlainText("Seed Phrase", seedPhrase)
|
val clip = android.content.ClipData.newPlainText("Seed Phrase", seedPhrase)
|
||||||
clipboard.setPrimaryClip(clip)
|
clipboard.setPrimaryClip(clip)
|
||||||
|
kotlinx.coroutines.MainScope().launch {
|
||||||
|
kotlinx.coroutines.delay(30_000)
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", ""))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ fun SafetyScreen(
|
|||||||
delay(2000)
|
delay(2000)
|
||||||
copiedPrivateKey = false
|
copiedPrivateKey = false
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
delay(30_000)
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
|
|||||||
@@ -45,13 +45,9 @@ fun SplashScreen(
|
|||||||
label = "alpha"
|
label = "alpha"
|
||||||
)
|
)
|
||||||
|
|
||||||
val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat(
|
val pulseScale by animateFloatAsState(
|
||||||
initialValue = 1f,
|
targetValue = if (startAnimation) 1.08f else 1f,
|
||||||
targetValue = 1.1f,
|
animationSpec = tween(900, easing = FastOutSlowInEasing),
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(800, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulseScale"
|
label = "pulseScale"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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