From 325073fc09b1538100f3b8151cab1c17f579f151 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 8 Apr 2026 19:10:53 +0500 Subject: [PATCH] =?UTF-8?q?QR-=D0=BA=D0=BE=D0=B4=D1=8B:=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=82=D0=B8=D0=BB=D0=B5=20Telegram=20(5=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC,=20=D1=86=D0=B2=D0=B5=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20QR,=20=D0=BB=D0=BE=D0=B3=D0=BE=D1=82=D0=B8=D0=BF,=20?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80),=20=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=20(CameraX=20+=20ML=20Kit),=20deep=20links?= =?UTF-8?q?=20(rosetta://=20+=20rosetta.im),=20Scan=20QR=20=D0=B2=20drawer?= =?UTF-8?q?,=20Share/Copy.=20=D0=A4=D0=B8=D0=BA=D1=81=20base64=20prefix=20?= =?UTF-8?q?=D0=B2=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B0=D1=85?= =?UTF-8?q?.=20Call:=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=87=D1=83=D0=B6=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=B5,=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=20(iOS=20parity),=20=D0=BC=D0=B3=D0=BD=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20rejected=20call.=20=D0=A1=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81-=D0=B1=D0=B0=D1=80:=20=D1=87=D1=91?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B1=D0=B5=D0=BB=D0=BE=D0=BC=20=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=20+=20restore=20=D0=BF=D1=80=D0=B8=20=D1=83=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5.=20=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20dev-=D0=BB=D0=BE=D0=B3=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 13 +- .../com/rosetta/messenger/MainActivity.kt | 81 ++++- .../messenger/data/MessageRepository.kt | 57 +++- .../rosetta/messenger/network/CallManager.kt | 23 +- .../messenger/network/ProtocolManager.kt | 15 - .../messenger/ui/chats/ChatViewModel.kt | 33 -- .../messenger/ui/chats/ChatsListScreen.kt | 19 +- .../messenger/ui/chats/calls/CallOverlay.kt | 132 +++++++- .../ui/chats/components/ImageViewerScreen.kt | 53 ---- .../rosetta/messenger/ui/qr/MyQrCodeScreen.kt | 293 ++++++++++++++++++ .../messenger/ui/qr/QrScannerScreen.kt | 176 +++++++++++ .../messenger/ui/settings/AppearanceScreen.kt | 6 +- .../ui/settings/BiometricEnableScreen.kt | 6 +- .../ui/settings/NotificationsScreen.kt | 11 + .../ui/settings/OtherProfileScreen.kt | 8 +- .../ui/settings/ProfileLogsScreen.kt | 6 +- .../messenger/ui/settings/ProfileScreen.kt | 240 +++++++------- .../messenger/ui/settings/SafetyScreen.kt | 11 + .../messenger/ui/settings/ThemeScreen.kt | 6 +- .../messenger/ui/settings/UpdatesScreen.kt | 6 +- .../messenger/utils/QrCodeGenerator.kt | 88 ++++++ 22 files changed, 1036 insertions(+), 251 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt create mode 100644 app/src/main/java/com/rosetta/messenger/utils/QrCodeGenerator.kt 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>(emptyMap()) } + + LaunchedEffect(isVisible, publicKey) { + if (isVisible && avatarRepository != null) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val allAvatars = avatarRepository.getAvatars(publicKey, allDecode = true) + val first = allAvatars.value + if (first.isNotEmpty()) { + avatarHistory = first + val bitmaps = mutableMapOf() + first.forEachIndexed { idx, info -> + try { + val raw = info.base64Data + val base64 = if (raw.contains(",")) raw.substringAfter(",") else raw + val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { + bitmaps[idx] = it + } + } catch (_: Exception) {} + } + decodedBitmaps = bitmaps + } + } + } } - // Swipe-to-dismiss offset + val pageCount = if (avatarHistory.isNotEmpty()) avatarHistory.size else 1 + val pagerState = androidx.compose.foundation.pager.rememberPagerState(pageCount = { pageCount }) + + // Current page timestamp + val currentTimestamp = if (avatarHistory.isNotEmpty() && pagerState.currentPage in avatarHistory.indices) { + avatarHistory[pagerState.currentPage].timestamp + } else avatarTimestamp + + // Swipe-to-dismiss var dragOffsetY by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } val dismissThreshold = screenHeight * 0.25f - // Animated values val animatedAlpha by animateFloatAsState( targetValue = if (showContent) { val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f) @@ -2056,7 +2104,6 @@ fun FullScreenAvatarViewer( animationSpec = tween(if (showContent && !isDragging) 250 else 200), label = "bg_alpha" ) - val animatedScale by animateFloatAsState( targetValue = if (showContent) { val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f) @@ -2065,29 +2112,24 @@ fun FullScreenAvatarViewer( animationSpec = tween(if (showContent && !isDragging) 250 else 200), label = "scale" ) - val animatedOffset by animateFloatAsState( - targetValue = if (isDragging) dragOffsetY else if (showContent) 0f else 0f, + targetValue = if (isDragging) dragOffsetY else 0f, animationSpec = if (isDragging) spring(stiffness = Spring.StiffnessHigh) else spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "offset", - finishedListener = { - if (!showContent) onDismiss() - } + finishedListener = { if (!showContent) onDismiss() } ) - // Date formatting - val dateText = remember(avatarTimestamp) { - if (avatarTimestamp > 0) { - val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH) - val normalizedTimestampMs = when { - avatarTimestamp >= 1_000_000_000_000_000_000L -> avatarTimestamp / 1_000_000 - avatarTimestamp >= 1_000_000_000_000_000L -> avatarTimestamp / 1_000 - avatarTimestamp >= 1_000_000_000_000L -> avatarTimestamp - else -> avatarTimestamp * 1_000 - } - sdf.format(Date(normalizedTimestampMs)) - } else "" + fun formatTimestamp(ts: Long): String { + if (ts <= 0) return "" + val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH) + val ms = when { + ts >= 1_000_000_000_000_000_000L -> ts / 1_000_000 + ts >= 1_000_000_000_000_000L -> ts / 1_000 + ts >= 1_000_000_000_000L -> ts + else -> ts * 1_000 + } + return sdf.format(Date(ms)) } if (isVisible) { @@ -2103,22 +2145,15 @@ fun FullScreenAvatarViewer( onDragStart = { isDragging = true }, onDragEnd = { isDragging = false - if (kotlin.math.abs(dragOffsetY) > dismissThreshold) { - showContent = false - } + if (kotlin.math.abs(dragOffsetY) > dismissThreshold) showContent = false dragOffsetY = 0f }, - onDragCancel = { - isDragging = false - dragOffsetY = 0f - }, - onVerticalDrag = { _, dragAmount -> - dragOffsetY += dragAmount - } + onDragCancel = { isDragging = false; dragOffsetY = 0f }, + onVerticalDrag = { _, dragAmount -> dragOffsetY += dragAmount } ) } ) { - // Avatar image centered + // Avatar pager Box( modifier = Modifier .fillMaxSize() @@ -2129,7 +2164,25 @@ fun FullScreenAvatarViewer( }, contentAlignment = Alignment.Center ) { - if (avatarBitmap != null) { + if (avatarHistory.size > 1) { + androidx.compose.foundation.pager.HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + key = { it } + ) { page -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val bmp = decodedBitmaps[page] + if (bmp != null) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Avatar ${page + 1}", + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + contentScale = ContentScale.Crop + ) + } + } + } + } else if (avatarBitmap != null) { Image( bitmap = avatarBitmap.asImageBitmap(), contentDescription = "Avatar", @@ -2139,9 +2192,7 @@ fun FullScreenAvatarViewer( } else { val avatarColors = getAvatarColor(publicKey, isDarkTheme) Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) + modifier = Modifier.fillMaxWidth().aspectRatio(1f) .background(avatarColors.backgroundColor), contentAlignment = Alignment.Center ) { @@ -2155,81 +2206,60 @@ fun FullScreenAvatarViewer( } } - // Top gradient for readability - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .align(Alignment.TopCenter) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.6f), - Color.Transparent - ) - ) - ) - ) + // Top gradient + Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.TopCenter) + .background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent)))) - // Bottom gradient shadow - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .align(Alignment.BottomCenter) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.6f) - ) - ) - ) - ) + // Bottom gradient + Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.BottomCenter) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f))))) - // Header: back button + name + date + // Page indicator dots + if (pageCount > 1) { + Row( + modifier = Modifier.align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 56.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + repeat(pageCount) { idx -> + Box( + modifier = Modifier + .weight(1f) + .height(2.dp) + .background( + if (idx == pagerState.currentPage) Color.White + else Color.White.copy(alpha = 0.35f), + RoundedCornerShape(1.dp) + ) + ) + } + } + } + + // Header Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() + modifier = Modifier.fillMaxWidth().statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { showContent = false }) { - Icon( - imageVector = TablerIcons.ChevronLeft, - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) + Icon(imageVector = TablerIcons.ChevronLeft, contentDescription = "Close", + tint = Color.White, modifier = Modifier.size(24.dp)) } - Column(modifier = Modifier.weight(1f)) { - Text( - text = displayName.ifBlank { publicKey.take(10) }, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text(text = displayName.ifBlank { publicKey.take(10) }, + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, + color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) + val dateText = formatTimestamp(currentTimestamp) if (dateText.isNotEmpty()) { - Text( - text = dateText, - fontSize = 13.sp, - color = Color.White.copy(alpha = 0.7f), - maxLines = 1 - ) + Text(text = dateText, fontSize = 13.sp, + color = Color.White.copy(alpha = 0.7f), maxLines = 1) } } - IconButton(onClick = { /* menu */ }) { - Icon( - painter = TelegramIcons.More, - contentDescription = "Menu", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) + Icon(painter = TelegramIcons.More, contentDescription = "Menu", + tint = Color.White, modifier = Modifier.size(24.dp)) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt index 8f02050..cda4039 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt @@ -55,6 +55,17 @@ fun SafetyScreen( var copiedPrivateKey by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + 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 } + } + } + // Handle back gesture BackHandler { onBack() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index 1a79615..d352958 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -75,11 +75,13 @@ fun ThemeScreen( ) { 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/UpdatesScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt index 9a00de8..33d27d8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt @@ -32,11 +32,13 @@ fun UpdatesScreen( val context = LocalContext.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/utils/QrCodeGenerator.kt b/app/src/main/java/com/rosetta/messenger/utils/QrCodeGenerator.kt new file mode 100644 index 0000000..faa60f4 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/QrCodeGenerator.kt @@ -0,0 +1,88 @@ +package com.rosetta.messenger.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.rosetta.messenger.R + +object QrCodeGenerator { + + fun generateQrBitmap( + content: String, + size: Int = 512, + foregroundColor: Int = Color.BLACK, + backgroundColor: Int = Color.WHITE, + context: Context? = null + ): Bitmap? { + return try { + val hints = mapOf( + EncodeHintType.MARGIN to 1, + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H // High error correction for logo overlay + ) + val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel(x, y, if (bitMatrix[x, y]) foregroundColor else backgroundColor) + } + } + + // Overlay logo in center + if (context != null) { + overlayLogo(bitmap, context) + } + + bitmap + } catch (_: Exception) { + null + } + } + + private fun overlayLogo(qrBitmap: Bitmap, context: Context) { + try { + val qrSize = qrBitmap.width + val logoSize = (qrSize * 0.22f).toInt() + val cx = qrSize / 2f + val cy = qrSize / 2f + val canvas = Canvas(qrBitmap) + + // White circle background + val bgRadius = logoSize * 0.65f + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + canvas.drawCircle(cx, cy, bgRadius, bgPaint) + + // Load logo from drawable + val drawable = androidx.core.content.ContextCompat.getDrawable(context, R.mipmap.ic_launcher_round) + if (drawable != null) { + val left = (cx - logoSize / 2f).toInt() + val top = (cy - logoSize / 2f).toInt() + drawable.setBounds(left, top, left + logoSize, top + logoSize) + drawable.draw(canvas) + } + } catch (_: Exception) {} + } + + /** Profile QR payload */ + fun profilePayload(publicKey: String): String = "rosetta://profile/$publicKey" + + /** Group QR payload */ + fun groupPayload(inviteString: String): String = "rosetta://group/$inviteString" + + /** Profile share URL */ + fun profileShareUrl(username: String): String = "https://rosetta.im/@$username" + + /** Profile share URL fallback (by key) */ + fun profileShareUrlByKey(publicKey: String): String = "https://rosetta.im/u/$publicKey" +}