diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0688d0b..8bcae18 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -207,6 +207,10 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
+ // QR Code generation (ZXing) + scanning (ML Kit)
+ implementation("com.google.zxing:core:3.5.3")
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
+
// Testing dependencies
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9a87c9b..cffe881 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -49,9 +49,20 @@
android:screenOrientation="portrait">
-
+
+
+
+
+
+
+
+
+
+
+
+
+ startCallWithPermission(callableUser)
}
)
}
@@ -1788,6 +1799,74 @@ fun MainScreen(
)
}
+ // QR Scanner
+ SwipeBackContainer(
+ isVisible = isQrScannerVisible,
+ onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } },
+ isDarkTheme = isDarkTheme,
+ layer = 3
+ ) {
+ com.rosetta.messenger.ui.qr.QrScannerScreen(
+ onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } },
+ onResult = { result ->
+ navStack = navStack.filterNot { it is Screen.QrScanner }
+ when (result.type) {
+ com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> {
+ mainScreenScope.launch {
+ val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000)
+ val user = users.firstOrNull()
+ if (user != null) {
+ pushScreen(Screen.OtherProfile(user))
+ } else {
+ val searchUser = com.rosetta.messenger.network.SearchUser(
+ publicKey = result.payload,
+ title = "", username = "", verified = 0, online = 0
+ )
+ pushScreen(Screen.ChatDetail(searchUser))
+ }
+ }
+ }
+ com.rosetta.messenger.ui.qr.QrResultType.GROUP -> {
+ mainScreenScope.launch {
+ val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context)
+ val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload)
+ if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) {
+ val groupUser = com.rosetta.messenger.network.SearchUser(
+ publicKey = joinResult.dialogPublicKey,
+ title = joinResult.title.ifBlank { "Group" },
+ username = "", verified = 0, online = 0
+ )
+ pushScreen(Screen.ChatDetail(groupUser))
+ }
+ }
+ }
+ else -> {}
+ }
+ }
+ )
+ }
+
+ // My QR Code
+ SwipeBackContainer(
+ isVisible = isMyQrVisible,
+ onBack = { navStack = navStack.filterNot { it is Screen.MyQr } },
+ isDarkTheme = isDarkTheme,
+ layer = 2
+ ) {
+ com.rosetta.messenger.ui.qr.MyQrCodeScreen(
+ isDarkTheme = isDarkTheme,
+ publicKey = accountPublicKey,
+ displayName = accountName,
+ username = accountUsername,
+ avatarRepository = avatarRepository,
+ onBack = { navStack = navStack.filterNot { it is Screen.MyQr } },
+ onScanQr = {
+ navStack = navStack.filterNot { it is Screen.MyQr }
+ pushScreen(Screen.QrScanner)
+ }
+ )
+ }
+
if (isCallScreenVisible) {
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
index 8cce209..bbb555a 100644
--- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt
@@ -1009,22 +1009,9 @@ class MessageRepository private constructor(private val context: Context) {
}
/** Обработка подтверждения доставки */
- private fun devLog(msg: String) {
- val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
- val line = "$ts [MsgRepo] $msg"
- android.util.Log.d("MsgRepo", msg)
- try {
- val dir = java.io.File(context.filesDir, "crash_reports")
- if (!dir.exists()) dir.mkdirs()
- java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
- } catch (_: Exception) {}
- }
-
suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return
- devLog("DELIVERY RECEIVED: msgId=${packet.messageId.take(8)}..., to=${packet.toPublicKey.take(12)}...")
-
MessageLogger.logDeliveryStatus(
messageId = packet.messageId,
toPublicKey = packet.toPublicKey,
@@ -1055,6 +1042,50 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
}
+ /**
+ * Save an incoming call event locally (for CALLEE side).
+ * Creates a message as if received from the peer, with CALL attachment.
+ */
+ suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
+ val account = currentAccount ?: return
+ val privateKey = currentPrivateKey ?: return
+ val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
+ val timestamp = System.currentTimeMillis()
+ val dialogKey = getDialogKey(fromPublicKey)
+
+ val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+ val attachmentsJson = org.json.JSONArray().apply {
+ put(org.json.JSONObject().apply {
+ put("id", attId)
+ put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
+ put("preview", durationSec.toString())
+ put("blob", "")
+ })
+ }.toString()
+
+ val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
+
+ val entity = com.rosetta.messenger.database.MessageEntity(
+ account = account,
+ fromPublicKey = fromPublicKey,
+ toPublicKey = account,
+ content = "",
+ timestamp = timestamp,
+ chachaKey = "",
+ read = 1,
+ fromMe = 0,
+ delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
+ messageId = messageId,
+ plainMessage = encryptedPlainMessage,
+ attachments = attachmentsJson,
+ primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
+ dialogKey = dialogKey
+ )
+ messageDao.insertMessage(entity)
+ dialogDao.updateDialogFromMessages(account, fromPublicKey)
+ _newMessageEvents.tryEmit(dialogKey)
+ }
+
/**
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник)
diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
index aadb546..5bf11cc 100644
--- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
@@ -1020,7 +1020,6 @@ object CallManager {
}
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
- if (role != CallRole.CALLER) return
val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return
@@ -1036,13 +1035,23 @@ object CallManager {
scope.launch {
runCatching {
- MessageRepository.getInstance(context).sendMessage(
- toPublicKey = peerPublicKey,
- text = "",
- attachments = listOf(callAttachment)
- )
+ if (role == CallRole.CALLER) {
+ // CALLER: send call attachment as a message (peer will receive it)
+ MessageRepository.getInstance(context).sendMessage(
+ toPublicKey = peerPublicKey,
+ text = "",
+ attachments = listOf(callAttachment)
+ )
+ } else {
+ // CALLEE: save call event locally (incoming from peer)
+ // CALLER will send their own message which may arrive later
+ MessageRepository.getInstance(context).saveIncomingCallEvent(
+ fromPublicKey = peerPublicKey,
+ durationSec = durationSec
+ )
+ }
}.onFailure { error ->
- Log.w(TAG, "Failed to send call attachment", error)
+ Log.w(TAG, "Failed to emit call attachment", error)
}
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt
index b98e545..c665119 100644
--- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt
@@ -58,17 +58,6 @@ object ProtocolManager {
private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private fun protocolDevLog(msg: String) {
- val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
- val line = "$ts [Protocol] $msg"
- android.util.Log.d("Protocol", msg)
- try {
- val ctx = appContext ?: return
- val dir = java.io.File(ctx.filesDir, "crash_reports")
- if (!dir.exists()) dir.mkdirs()
- java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
- } catch (_: Exception) {}
- }
@Volatile private var packetHandlersRegistered = false
@Volatile private var stateMonitoringStarted = false
@Volatile private var syncRequestInFlight = false
@@ -349,12 +338,10 @@ object ProtocolManager {
// Обработчик доставки (0x08)
waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery
- protocolDevLog("PACKET 0x08 DELIVERY: msgId=${deliveryPacket.messageId.take(8)}..., to=${deliveryPacket.toPublicKey.take(12)}...")
launchInboundPacketTask {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
- protocolDevLog(" DELIVERY SKIPPED: repo not init")
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
markInboundProcessingFailure("Delivery packet skipped before account init")
return@launchInboundPacketTask
@@ -362,9 +349,7 @@ object ProtocolManager {
try {
repository.handleDelivery(deliveryPacket)
resolveOutgoingRetry(deliveryPacket.messageId)
- protocolDevLog(" DELIVERY HANDLED OK: ${deliveryPacket.messageId.take(8)}...")
} catch (e: Exception) {
- protocolDevLog(" DELIVERY ERROR: ${e.javaClass.simpleName}: ${e.message}")
markInboundProcessingFailure("Delivery processing failed", e)
return@launchInboundPacketTask
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
index 0f14dfc..82b2d68 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
@@ -37,18 +37,6 @@ import org.json.JSONObject
*/
class ChatViewModel(application: Application) : AndroidViewModel(application) {
- private fun devLog(msg: String) {
- val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
- val line = "$ts [SendMsg] $msg"
- android.util.Log.d("SendMsg", msg)
- try {
- val ctx = getApplication()
- val dir = java.io.File(ctx.filesDir, "crash_reports")
- if (!dir.exists()) dir.mkdirs()
- java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
- } catch (_: Exception) {}
- }
-
companion object {
private const val TAG = "ChatViewModel"
private const val PAGE_SIZE = 30
@@ -541,26 +529,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val opponent = opponentKey ?: return@collect
val currentDialogKey = getDialogKey(account, opponent)
- devLog("DELIVERY EVENT: msgId=${update.messageId.take(8)}..., status=${update.status}, dialogKey=${update.dialogKey.take(20)}..., currentKey=${currentDialogKey.take(20)}..., match=${update.dialogKey == currentDialogKey}")
-
if (update.dialogKey == currentDialogKey) {
when (update.status) {
DeliveryStatus.DELIVERED -> {
- devLog(" → updateMessageStatus DELIVERED: ${update.messageId.take(8)}...")
updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
}
DeliveryStatus.ERROR -> {
- devLog(" → updateMessageStatus ERROR: ${update.messageId.take(8)}...")
updateMessageStatus(update.messageId, MessageStatus.ERROR)
}
DeliveryStatus.READ -> {
- devLog(" → markAllOutgoingAsRead")
markAllOutgoingAsRead()
}
else -> {}
}
- } else {
- devLog(" → SKIPPED (dialog mismatch)")
}
}
}
@@ -620,8 +601,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val currentMessages = _messages.value
val currentStatus = currentMessages.find { it.id == messageId }?.status
- devLog("updateMessageStatus: msgId=${messageId.take(8)}..., newStatus=$status, currentStatus=$currentStatus, totalMsgs=${currentMessages.size}")
-
_messages.value =
currentMessages.map { msg ->
if (msg.id != messageId) return@map msg
@@ -648,10 +627,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
if (mergedStatus != msg.status) {
- devLog(" → STATUS CHANGED: ${msg.status} → $mergedStatus")
msg.copy(status = mergedStatus)
} else {
- devLog(" → STATUS NOT UPGRADED: ${msg.status} ≥ $status, skip")
msg
}
}
@@ -2872,9 +2849,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
- devLog("=== SEND START: msgId=${messageId.take(8)}..., to=${recipient.take(12)}..., text='${text.take(30)}' ===")
- devLog(" isForward=$isForward, replyMsgs=${replyMsgsToSend.size}, isConnected=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
-
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Используется для обычного reply (не forward).
val replyData: ReplyData? =
@@ -3136,11 +3110,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер
val isSavedMessages = (sender == recipient)
if (!isSavedMessages) {
- devLog(" SENDING packet: msgId=${messageId.take(8)}..., isConnected=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
ProtocolManager.send(packet)
- devLog(" SENT packet OK: msgId=${messageId.take(8)}...")
- } else {
- devLog(" Saved Messages — skip send")
}
withContext(Dispatchers.Main) {
@@ -3209,7 +3179,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(text, timestamp)
} catch (e: Exception) {
- devLog(" SEND ERROR: msgId=${messageId.take(8)}..., ${e.javaClass.simpleName}: ${e.message}")
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
@@ -3430,9 +3399,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachments = finalMessageAttachments
}
if (!isSavedMessages) {
- devLog(" FWD SENDING: msgId=${messageId.take(8)}..., to=${recipientPublicKey.take(12)}..., isConn=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
ProtocolManager.send(packet)
- devLog(" FWD SENT OK: msgId=${messageId.take(8)}...")
}
val finalAttachmentsJson =
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 991ad0e..1911a4b 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
@@ -286,7 +286,9 @@ fun ChatsListScreen(
onOpenCallOverlay: () -> Unit = {},
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {},
- onDeleteAccountFromSidebar: (String) -> Unit = {}
+ onDeleteAccountFromSidebar: (String) -> Unit = {},
+ onQrScanClick: () -> Unit = {},
+ onMyQrClick: () -> Unit = {}
) {
// Theme transition state
var hasInitialized by remember { mutableStateOf(false) }
@@ -1209,6 +1211,21 @@ fun ChatsListScreen(
}
)
+ // 📷 Scan QR
+ DrawerMenuItemEnhanced(
+ icon = TablerIcons.Scan,
+ text = "Scan QR",
+ iconColor = menuIconColor,
+ textColor = menuTextColor,
+ onClick = {
+ scope.launch {
+ drawerState.close()
+ kotlinx.coroutines.delay(100)
+ onQrScanClick()
+ }
+ }
+ )
+
// 📖 Saved Messages
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved),
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt
index 38f5a1a..09b7907 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt
@@ -51,11 +51,9 @@ import com.rosetta.messenger.data.MessageRepository
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown
-// ── Telegram-style dark gradient colors ──────────────────────────
+// ── Call colors ──────────────────────────────────────────────────
-private val GradientTop = Color(0xFF1A1A2E)
-private val GradientMid = Color(0xFF16213E)
-private val GradientBottom = Color(0xFF0F3460)
+private val CallBaseBg = Color(0xFF0D0D14)
private val AcceptGreen = Color(0xFF4CC764)
private val DeclineRed = Color(0xFFE74C3C)
private val ButtonBg = Color.White.copy(alpha = 0.15f)
@@ -64,6 +62,124 @@ private val RingColor1 = Color.White.copy(alpha = 0.06f)
private val RingColor2 = Color.White.copy(alpha = 0.10f)
private val RingColor3 = Color.White.copy(alpha = 0.04f)
+// Avatar color palette (Mantine colors like iOS)
+private val AvatarCallColors = listOf(
+ Color(0xFF228BE6), Color(0xFF15AABF), Color(0xFFBE4BDB),
+ Color(0xFF40C057), Color(0xFF4C6EF5), Color(0xFF82C91E),
+ Color(0xFFFD7E14), Color(0xFFE64980), Color(0xFFFA5252),
+ Color(0xFF12B886), Color(0xFF7950F2)
+)
+
+private fun callColorForKey(publicKey: String): Color {
+ val hash = publicKey.sumOf { it.code }
+ return AvatarCallColors[hash % AvatarCallColors.size]
+}
+
+/** Animated gradient background with 3 moving blobs — iOS parity */
+@Composable
+private fun CallGradientBackground(peerPublicKey: String) {
+ val primary = remember(peerPublicKey) { callColorForKey(peerPublicKey) }
+
+ // Hue-shifted + darkened variants
+ val blob2Color = remember(primary) {
+ val hsv = FloatArray(3)
+ android.graphics.Color.colorToHSV(
+ android.graphics.Color.argb(255,
+ (primary.red * 255).toInt(),
+ (primary.green * 255).toInt(),
+ (primary.blue * 255).toInt()
+ ), hsv
+ )
+ hsv[0] = (hsv[0] + 25f) % 360f
+ hsv[2] = hsv[2] * 0.7f
+ Color(android.graphics.Color.HSVToColor(hsv))
+ }
+ val blob3Color = remember(primary) {
+ val hsv = FloatArray(3)
+ android.graphics.Color.colorToHSV(
+ android.graphics.Color.argb(255,
+ (primary.red * 255).toInt(),
+ (primary.green * 255).toInt(),
+ (primary.blue * 255).toInt()
+ ), hsv
+ )
+ hsv[2] = hsv[2] * 0.27f
+ Color(android.graphics.Color.HSVToColor(hsv))
+ }
+
+ val infiniteTransition = rememberInfiniteTransition(label = "callBg")
+ val time by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1000_000, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "time"
+ )
+
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val w = size.width
+ val h = size.height
+ val maxDim = maxOf(w, h)
+ val t = time
+
+ // Base dark fill
+ drawRect(CallBaseBg)
+
+ // Blob 1 — primary color
+ val x1 = w * 0.5f + w * 0.3f * kotlin.math.sin(t * 0.23f)
+ val y1 = h * 0.35f + h * 0.2f * kotlin.math.sin(t * 0.19f + 1f)
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(
+ primary.copy(alpha = 0.55f),
+ primary.copy(alpha = 0.22f),
+ Color.Transparent
+ ),
+ center = Offset(x1, y1),
+ radius = maxDim * 0.7f
+ ),
+ radius = maxDim * 0.7f,
+ center = Offset(x1, y1)
+ )
+
+ // Blob 2 — hue shifted
+ val x2 = w * 0.4f + w * 0.35f * kotlin.math.sin(t * 0.17f + 2f)
+ val y2 = h * 0.55f + h * 0.25f * kotlin.math.sin(t * 0.21f + 3.5f)
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(
+ blob2Color.copy(alpha = 0.45f),
+ blob2Color.copy(alpha = 0.18f),
+ Color.Transparent
+ ),
+ center = Offset(x2, y2),
+ radius = maxDim * 0.65f
+ ),
+ radius = maxDim * 0.65f,
+ center = Offset(x2, y2)
+ )
+
+ // Blob 3 — dark
+ val x3 = w * 0.6f + w * 0.3f * kotlin.math.sin(t * 0.29f + 4f)
+ val y3 = h * 0.7f + h * 0.15f * kotlin.math.sin(t * 0.15f + 5.5f)
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(
+ blob3Color.copy(alpha = 0.5f),
+ blob3Color.copy(alpha = 0.2f),
+ Color.Transparent
+ ),
+ center = Offset(x3, y3),
+ radius = maxDim * 0.6f
+ ),
+ radius = maxDim * 0.6f,
+ center = Offset(x3, y3)
+ )
+ }
+}
+
// ── Main Call Screen ─────────────────────────────────────────────
@Composable
@@ -103,12 +219,10 @@ fun CallOverlay(
exit = fadeOut(tween(200))
) {
Box(
- modifier = Modifier
- .fillMaxSize()
- .background(
- Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
- )
+ modifier = Modifier.fillMaxSize()
) {
+ // Animated gradient background (iOS parity)
+ CallGradientBackground(peerPublicKey = uiState.peerPublicKey)
// ── Top controls: minimize (left) + key cast QR (right) ──
val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE
val showKeyCast =
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt
index 40fba9d..792f5bb 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt
@@ -977,133 +977,82 @@ private fun ZoomableImage(
* 2) из локального encrypted attachment файла
* 3) с transport (с последующим сохранением в локальный файл)
*/
-private fun viewerLog(context: Context, msg: String) {
- val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
- val line = "$ts [ViewerImage] $msg"
- android.util.Log.d("ViewerImage", msg)
- try {
- val dir = java.io.File(context.filesDir, "crash_reports")
- if (!dir.exists()) dir.mkdirs()
- val f = java.io.File(dir, "rosettadev1.txt")
- f.appendText("$line\n")
- } catch (_: Exception) {}
-}
-
private suspend fun loadBitmapForViewerImage(
context: Context,
image: ViewableImage,
privateKey: String
): Bitmap? {
- val idShort = if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
- viewerLog(context, "=== LOAD START: id=$idShort ===")
- viewerLog(context, " blob.len=${image.blob.length}, preview.len=${image.preview.length}")
- viewerLog(context, " chachaKey.len=${image.chachaKey.length}, chachaKeyPlainHex.len=${image.chachaKeyPlainHex.length}")
- viewerLog(context, " senderPK=${image.senderPublicKey.take(12)}..., privateKey.len=${privateKey.length}")
- viewerLog(context, " width=${image.width}, height=${image.height}")
-
return try {
// 0. In-memory кэш
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
if (cached != null) {
- viewerLog(context, " [0] HIT ImageBitmapCache → OK (${cached.width}x${cached.height})")
return cached
}
- viewerLog(context, " [0] MISS ImageBitmapCache")
// 1. Blob в сообщении
if (image.blob.isNotEmpty()) {
- viewerLog(context, " [1] blob present (${image.blob.length} chars), decoding...")
val bmp = base64ToBitmapSafe(image.blob)
if (bmp != null) {
- viewerLog(context, " [1] blob decode → OK (${bmp.width}x${bmp.height})")
return bmp
}
- viewerLog(context, " [1] blob decode → FAILED")
- } else {
- viewerLog(context, " [1] blob empty, skip")
}
// 2. Локальный encrypted cache
- viewerLog(context, " [2] readAttachment(id=$idShort, sender=${image.senderPublicKey.take(12)}...)")
val localBlob =
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
if (localBlob != null) {
- viewerLog(context, " [2] local file found (${localBlob.length} chars), decoding...")
val bmp = base64ToBitmapSafe(localBlob)
if (bmp != null) {
- viewerLog(context, " [2] local decode → OK (${bmp.width}x${bmp.height})")
return bmp
}
- viewerLog(context, " [2] local decode → FAILED")
- } else {
- viewerLog(context, " [2] local file NOT found")
}
// 2.5. Ждём bitmap из кеша
- viewerLog(context, " [2.5] awaitCached 3s...")
val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000)
if (awaitedFromCache != null) {
- viewerLog(context, " [2.5] await → OK (${awaitedFromCache.width}x${awaitedFromCache.height})")
return awaitedFromCache
}
- viewerLog(context, " [2.5] await → timeout, not found")
// 3. CDN download
var downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) {
downloadTag = image.transportTag
}
- viewerLog(context, " [3] downloadTag='${downloadTag.take(16)}...', transportTag='${image.transportTag.take(16)}...', preview='${image.preview.take(30)}...'")
if (downloadTag.isEmpty()) {
- viewerLog(context, " [3] downloadTag EMPTY → FAIL")
return null
}
- val server = TransportManager.getTransportServer() ?: "unset"
- viewerLog(context, " [3] CDN download: server=$server")
-
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
- viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes")
if (encryptedContent.isEmpty()) {
- viewerLog(context, " [3] CDN response EMPTY → FAIL")
return null
}
// Decrypt
val decrypted: String? =
if (image.chachaKeyPlainHex.isNotEmpty()) {
- viewerLog(context, " [3] decrypt via chachaKeyPlainHex (${image.chachaKeyPlainHex.length} hex chars)")
val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
} else if (image.chachaKey.startsWith("group:")) {
- viewerLog(context, " [3] decrypt via group key")
val groupPassword = CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"), privateKey
)
if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null
} else if (image.chachaKey.isNotEmpty()) {
- viewerLog(context, " [3] decrypt via chachaKey (${image.chachaKey.length} chars)")
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
- viewerLog(context, " [3] decryptKeyFromSender → keySize=${decryptedKeyAndNonce.size}")
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
} else {
- viewerLog(context, " [3] NO chachaKey available → FAIL")
null
}
if (decrypted == null) {
- viewerLog(context, " [3] decrypt → NULL → FAIL")
return null
}
- viewerLog(context, " [3] decrypted OK (${decrypted.length} chars)")
val decodedBitmap = base64ToBitmapSafe(decrypted)
if (decodedBitmap == null) {
- viewerLog(context, " [3] base64→bitmap → FAILED")
return null
}
- viewerLog(context, " [3] bitmap OK (${decodedBitmap.width}x${decodedBitmap.height})")
// Сохраняем локально
AttachmentFileManager.saveAttachment(
@@ -1113,10 +1062,8 @@ private suspend fun loadBitmapForViewerImage(
publicKey = image.senderPublicKey,
privateKey = privateKey
)
- viewerLog(context, " saved locally → DONE")
decodedBitmap
} catch (e: Exception) {
- viewerLog(context, " EXCEPTION: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
new file mode 100644
index 0000000..fd9c9dd
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
@@ -0,0 +1,293 @@
+package com.rosetta.messenger.ui.qr
+
+import android.content.Intent
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rosetta.messenger.repository.AvatarRepository
+import com.rosetta.messenger.ui.components.AvatarImage
+import com.rosetta.messenger.utils.QrCodeGenerator
+import compose.icons.TablerIcons
+import compose.icons.tablericons.*
+
+// Theme presets — gradient background + QR foreground color
+private data class QrTheme(
+ val gradientColors: List,
+ val qrColor: Int,
+ val name: String
+)
+
+private val qrThemes = listOf(
+ QrTheme(
+ listOf(Color(0xFF667EEA), Color(0xFF764BA2)),
+ 0xFF4A3F9F.toInt(),
+ "Purple"
+ ),
+ QrTheme(
+ listOf(Color(0xFF43E97B), Color(0xFF38F9D7)),
+ 0xFF2D8F5E.toInt(),
+ "Green"
+ ),
+ QrTheme(
+ listOf(Color(0xFFF78CA0), Color(0xFFF9748F), Color(0xFFFD868C)),
+ 0xFFBF3A5A.toInt(),
+ "Pink"
+ ),
+ QrTheme(
+ listOf(Color(0xFF4FACFE), Color(0xFF00F2FE)),
+ 0xFF2171B5.toInt(),
+ "Blue"
+ ),
+ QrTheme(
+ listOf(Color(0xFFFFA751), Color(0xFFFFE259)),
+ 0xFFC67B1C.toInt(),
+ "Orange"
+ ),
+)
+
+@Composable
+fun MyQrCodeScreen(
+ isDarkTheme: Boolean,
+ publicKey: String,
+ displayName: String,
+ username: String,
+ avatarRepository: AvatarRepository? = null,
+ onBack: () -> Unit,
+ onScanQr: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ var selectedThemeIndex by remember { mutableIntStateOf(0) }
+ val theme = qrThemes[selectedThemeIndex]
+
+ val qrBitmap = remember(publicKey, selectedThemeIndex) {
+ QrCodeGenerator.generateQrBitmap(
+ content = QrCodeGenerator.profilePayload(publicKey),
+ size = 768,
+ foregroundColor = theme.qrColor,
+ backgroundColor = android.graphics.Color.WHITE,
+ context = context
+ )
+ }
+
+ val shareUrl = remember(username, publicKey) {
+ if (username.isNotBlank()) QrCodeGenerator.profileShareUrl(username)
+ else QrCodeGenerator.profileShareUrlByKey(publicKey)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Brush.verticalGradient(theme.gradientColors))
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.statusBarsPadding().height(40.dp))
+
+ // QR Card with avatar overlapping
+ Box(
+ modifier = Modifier.padding(horizontal = 32.dp),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ // White card
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 40.dp),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 52.dp, bottom = 24.dp, start = 24.dp, end = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // QR code
+ if (qrBitmap != null) {
+ Image(
+ bitmap = qrBitmap.asImageBitmap(),
+ contentDescription = "QR Code",
+ modifier = Modifier.size(220.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Username
+ Text(
+ text = if (username.isNotBlank()) "@${username.uppercase()}"
+ else displayName.uppercase(),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(theme.qrColor),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ // Avatar overlapping the card top
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(Color.White)
+ .padding(3.dp)
+ .clip(CircleShape)
+ ) {
+ AvatarImage(
+ publicKey = publicKey,
+ avatarRepository = avatarRepository,
+ size = 74.dp,
+ isDarkTheme = false,
+ displayName = displayName
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Bottom sheet area
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
+ tonalElevation = 0.dp,
+ shadowElevation = 16.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(top = 16.dp, bottom = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Handle bar
+ Box(
+ modifier = Modifier
+ .width(36.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(Color.Gray.copy(alpha = 0.3f))
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Header: Close + title
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ TablerIcons.X,
+ contentDescription = "Close",
+ tint = if (isDarkTheme) Color.White else Color.Black
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ "QR Code",
+ fontSize = 17.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = if (isDarkTheme) Color.White else Color.Black
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Theme selector
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ qrThemes.forEachIndexed { index, t ->
+ val isSelected = index == selectedThemeIndex
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(0.85f)
+ .clip(RoundedCornerShape(12.dp))
+ .border(
+ width = if (isSelected) 2.dp else 0.dp,
+ color = if (isSelected) Color(0xFF3390EC) else Color.Transparent,
+ shape = RoundedCornerShape(12.dp)
+ )
+ .background(Brush.verticalGradient(t.gradientColors))
+ .clickable { selectedThemeIndex = index },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ TablerIcons.Scan,
+ contentDescription = t.name,
+ tint = Color.White.copy(alpha = 0.8f),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Share button
+ Button(
+ onClick = {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, "Add me on Rosetta: $shareUrl")
+ }
+ context.startActivity(Intent.createChooser(intent, "Share Profile"))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .height(50.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3390EC)),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text("Share", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Scan QR button
+ TextButton(onClick = onScanQr) {
+ Icon(
+ TablerIcons.Scan,
+ contentDescription = null,
+ tint = Color(0xFF3390EC),
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ "Scan QR Code",
+ color = Color(0xFF3390EC),
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..0ee97bc
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt
@@ -0,0 +1,176 @@
+package com.rosetta.messenger.ui.qr
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.util.Size
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import compose.icons.TablerIcons
+import compose.icons.tablericons.X
+import java.util.concurrent.Executors
+
+data class QrScanResult(
+ val type: QrResultType,
+ val payload: String // publicKey, inviteString, or username
+)
+
+enum class QrResultType { PROFILE, GROUP, UNKNOWN }
+
+fun parseQrContent(raw: String): QrScanResult {
+ val trimmed = raw.trim()
+ return when {
+ trimmed.startsWith("rosetta://profile/") ->
+ QrScanResult(QrResultType.PROFILE, trimmed.removePrefix("rosetta://profile/"))
+ trimmed.startsWith("rosetta://group/") ->
+ QrScanResult(QrResultType.GROUP, trimmed.removePrefix("rosetta://group/"))
+ trimmed.startsWith("https://rosetta.im/@") ->
+ QrScanResult(QrResultType.PROFILE, trimmed.removePrefix("https://rosetta.im/@"))
+ trimmed.startsWith("https://rosetta.im/u/") ->
+ QrScanResult(QrResultType.PROFILE, trimmed.removePrefix("https://rosetta.im/u/"))
+ trimmed.startsWith("#group:") ->
+ QrScanResult(QrResultType.GROUP, trimmed)
+ else -> QrScanResult(QrResultType.UNKNOWN, trimmed)
+ }
+}
+
+@Composable
+fun QrScannerScreen(
+ onBack: () -> Unit,
+ onResult: (QrScanResult) -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ var hasCameraPermission by remember {
+ mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
+ }
+ val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ hasCameraPermission = it
+ }
+
+ LaunchedEffect(Unit) {
+ if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+
+ var scannedOnce by remember { mutableStateOf(false) }
+
+ 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
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Camera permission required", color = Color.White, fontSize = 16.sp)
+ }
+ }
+
+ // Viewfinder overlay
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier
+ .size(250.dp)
+ .border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(16.dp))
+ )
+ }
+
+ // Top bar
+ Row(
+ modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(TablerIcons.X, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(28.dp))
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text("Scan QR Code", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+
+ // Bottom hint
+ Text(
+ text = "Point your camera at a Rosetta QR code",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp).fillMaxWidth()
+ )
+ }
+}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
index 0b1f410..0956fef 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt
@@ -84,11 +84,13 @@ fun AppearanceScreen(
) {
val view = LocalView.current
if (!view.isInEditMode) {
- SideEffect {
+ DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
- insetsController.isAppearanceLightStatusBars = false
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt
index da1ceaa..6d1ef12 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt
@@ -84,11 +84,13 @@ fun BiometricEnableScreen(
val context = LocalContext.current
val view = LocalView.current
if (!view.isInEditMode) {
- SideEffect {
+ DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
- insetsController.isAppearanceLightStatusBars = false
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt
index fbf5c47..8b4c73c 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt
@@ -49,6 +49,17 @@ fun NotificationsScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
+ val view = androidx.compose.ui.platform.LocalView.current
+ if (!view.isInEditMode) {
+ androidx.compose.runtime.DisposableEffect(isDarkTheme) {
+ val window = (view.context as android.app.Activity).window
+ val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
+ }
+ }
+
BackHandler { onBack() }
Column(
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt
index f141c79..a0f553c 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt
@@ -190,7 +190,8 @@ fun OtherProfileScreen(
currentUserPublicKey: String = "",
currentUserPrivateKey: String = "",
backgroundBlurColorId: String = "avatar",
- onWriteMessage: (SearchUser) -> Unit = {}
+ onWriteMessage: (SearchUser) -> Unit = {},
+ onCall: (SearchUser) -> Unit = {}
) {
var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) }
@@ -709,7 +710,7 @@ fun OtherProfileScreen(
if (!isSafetyProfile && !isSystemAccount) {
// Call
Button(
- onClick = { /* TODO: call action */ },
+ onClick = { onCall(user) },
modifier = Modifier
.weight(1f)
.height(48.dp),
@@ -1187,7 +1188,8 @@ fun OtherProfileScreen(
avatarTimestamp = avatarViewerTimestamp,
avatarBitmap = avatarViewerBitmap,
publicKey = user.publicKey,
- isDarkTheme = isDarkTheme
+ isDarkTheme = isDarkTheme,
+ avatarRepository = avatarRepository
)
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt
index 6c4a1bf..fb1b3e7 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt
@@ -29,11 +29,13 @@ fun ProfileLogsScreen(
) {
val view = LocalView.current
if (!view.isInEditMode) {
- SideEffect {
+ DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
- insetsController.isAppearanceLightStatusBars = false
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
index 788a310..7943570 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
@@ -284,6 +284,7 @@ fun ProfileScreen(
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
onNavigateToBiometric: () -> Unit = {},
+ onNavigateToMyQr: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null,
@@ -291,11 +292,13 @@ fun ProfileScreen(
) {
val view = LocalView.current
if (!view.isInEditMode) {
- SideEffect {
+ DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
- insetsController.isAppearanceLightStatusBars = false
+ val prev = insetsController.isAppearanceLightStatusBars
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT
+ onDispose { insetsController.isAppearanceLightStatusBars = prev }
}
}
@@ -902,11 +905,20 @@ fun ProfileScreen(
onNavigateToBiometric()
},
isDarkTheme = isDarkTheme,
- showDivider = false,
+ showDivider = true,
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled"
)
}
+ TelegramSettingsItem(
+ icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(compose.icons.TablerIcons.Scan),
+ title = "QR Code",
+ onClick = onNavigateToMyQr,
+ isDarkTheme = isDarkTheme,
+ showDivider = false,
+ subtitle = "Share your profile"
+ )
+
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
@@ -1059,7 +1071,8 @@ fun ProfileScreen(
avatarTimestamp = avatarViewerTimestamp,
avatarBitmap = avatarViewerBitmap,
publicKey = accountPublicKey,
- isDarkTheme = isDarkTheme
+ isDarkTheme = isDarkTheme,
+ avatarRepository = avatarRepository
)
}
@@ -2024,6 +2037,7 @@ fun ProfileNavigationItem(
// Long-press avatar → full screen with swipe-down to dismiss
// ═════════════════════════════════════════════════════════════
@Composable
+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
fun FullScreenAvatarViewer(
isVisible: Boolean,
onDismiss: () -> Unit,
@@ -2031,23 +2045,57 @@ fun FullScreenAvatarViewer(
avatarTimestamp: Long,
avatarBitmap: android.graphics.Bitmap?,
publicKey: String,
- isDarkTheme: Boolean
+ isDarkTheme: Boolean,
+ avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null
) {
+ val context = LocalContext.current
val density = LocalDensity.current
val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
- // Animated visibility
var showContent by remember { mutableStateOf(false) }
- LaunchedEffect(isVisible) {
- showContent = isVisible
+ LaunchedEffect(isVisible) { showContent = isVisible }
+
+ // Load avatar history
+ var avatarHistory by remember { mutableStateOf(emptyList()) }
+ var decodedBitmaps by remember { mutableStateOf