QR-коды: экран профиля в стиле Telegram (5 тем, цветной QR, логотип, аватар), сканер (CameraX + ML Kit), deep links (rosetta:// + rosetta.im), Scan QR в drawer, Share/Copy. Фикс base64 prefix в аватарках. Call: кнопка на чужом профиле, анимированный градиентный фон (iOS parity), мгновенный rejected call. Статус-бар: чёрные иконки на белом фоне + restore при уходе. Удалены dev-логи.

This commit is contained in:
2026-04-08 19:10:53 +05:00
parent 8bfbba3159
commit 325073fc09
22 changed files with 1036 additions and 251 deletions

View File

@@ -207,6 +207,10 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx") implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-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 // Testing dependencies
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.mockk:mockk:1.13.8")

View File

@@ -49,9 +49,20 @@
android:screenOrientation="portrait"> android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rosetta" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="rosetta.im" />
</intent-filter>
</activity> </activity>
<activity <activity

View File

@@ -671,6 +671,8 @@ sealed class Screen {
data object CrashLogs : Screen() data object CrashLogs : Screen()
data object Biometric : Screen() data object Biometric : Screen()
data object Appearance : Screen() data object Appearance : Screen()
data object QrScanner : Screen()
data object MyQr : Screen()
} }
@Composable @Composable
@@ -1028,6 +1030,8 @@ fun MainScreen(
val isAppearanceVisible by remember { val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } } derivedStateOf { navStack.any { it is Screen.Appearance } }
} }
val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } }
val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) } var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) } var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
var discardProfileChangesFromChat by remember { mutableStateOf(false) } var discardProfileChangesFromChat by remember { mutableStateOf(false) }
@@ -1240,7 +1244,9 @@ fun MainScreen(
onAddAccount() onAddAccount()
}, },
onSwitchAccount = onSwitchAccount, onSwitchAccount = onSwitchAccount,
onDeleteAccountFromSidebar = onDeleteAccountFromSidebar onDeleteAccountFromSidebar = onDeleteAccountFromSidebar,
onQrScanClick = { pushScreen(Screen.QrScanner) },
onMyQrClick = { pushScreen(Screen.MyQr) }
) )
} }
@@ -1304,6 +1310,7 @@ fun MainScreen(
onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToSafety = { pushScreen(Screen.Safety) },
onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToLogs = { pushScreen(Screen.Logs) },
onNavigateToBiometric = { pushScreen(Screen.Biometric) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) },
onNavigateToMyQr = { pushScreen(Screen.MyQr) },
viewModel = profileViewModel, viewModel = profileViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
@@ -1542,6 +1549,7 @@ fun MainScreen(
onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToSafety = { pushScreen(Screen.Safety) },
onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToLogs = { pushScreen(Screen.Logs) },
onNavigateToBiometric = { pushScreen(Screen.Biometric) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) },
onNavigateToMyQr = { pushScreen(Screen.MyQr) },
viewModel = profileViewModel, viewModel = profileViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
@@ -1710,6 +1718,9 @@ fun MainScreen(
navStack = navStack.filterNot { navStack = navStack.filterNot {
it is Screen.OtherProfile || it is Screen.ChatDetail it is Screen.OtherProfile || it is Screen.ChatDetail
} + Screen.ChatDetail(chatUser) } + Screen.ChatDetail(chatUser)
},
onCall = { callableUser ->
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) { if (isCallScreenVisible) {
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay. // Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.). // Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).

View File

@@ -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) { suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return val account = currentAccount ?: return
devLog("DELIVERY RECEIVED: msgId=${packet.messageId.take(8)}..., to=${packet.toPublicKey.take(12)}...")
MessageLogger.logDeliveryStatus( MessageLogger.logDeliveryStatus(
messageId = packet.messageId, messageId = packet.messageId,
toPublicKey = packet.toPublicKey, toPublicKey = packet.toPublicKey,
@@ -1055,6 +1042,50 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, packet.toPublicKey) 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 сообщает что собеседник прочитал наши сообщения * Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник) * fromPublicKey - кто прочитал (собеседник)

View File

@@ -1020,7 +1020,6 @@ object CallManager {
} }
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) { private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
if (role != CallRole.CALLER) return
val peerPublicKey = snapshot.peerPublicKey.trim() val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return val context = appContext ?: return
if (peerPublicKey.isBlank()) return if (peerPublicKey.isBlank()) return
@@ -1036,13 +1035,23 @@ object CallManager {
scope.launch { scope.launch {
runCatching { runCatching {
if (role == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage( MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey, toPublicKey = peerPublicKey,
text = "", text = "",
attachments = listOf(callAttachment) 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 -> }.onFailure { error ->
Log.w(TAG, "Failed to send call attachment", error) Log.w(TAG, "Failed to emit call attachment", error)
} }
} }
} }

View File

@@ -58,17 +58,6 @@ object ProtocolManager {
private var appContext: Context? = null private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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 packetHandlersRegistered = false
@Volatile private var stateMonitoringStarted = false @Volatile private var stateMonitoringStarted = false
@Volatile private var syncRequestInFlight = false @Volatile private var syncRequestInFlight = false
@@ -349,12 +338,10 @@ object ProtocolManager {
// Обработчик доставки (0x08) // Обработчик доставки (0x08)
waitPacket(0x08) { packet -> waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery val deliveryPacket = packet as PacketDelivery
protocolDevLog("PACKET 0x08 DELIVERY: msgId=${deliveryPacket.messageId.take(8)}..., to=${deliveryPacket.toPublicKey.take(12)}...")
launchInboundPacketTask { launchInboundPacketTask {
val repository = messageRepository val repository = messageRepository
if (repository == null || !repository.isInitialized()) { if (repository == null || !repository.isInitialized()) {
protocolDevLog(" DELIVERY SKIPPED: repo not init")
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync") requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
markInboundProcessingFailure("Delivery packet skipped before account init") markInboundProcessingFailure("Delivery packet skipped before account init")
return@launchInboundPacketTask return@launchInboundPacketTask
@@ -362,9 +349,7 @@ object ProtocolManager {
try { try {
repository.handleDelivery(deliveryPacket) repository.handleDelivery(deliveryPacket)
resolveOutgoingRetry(deliveryPacket.messageId) resolveOutgoingRetry(deliveryPacket.messageId)
protocolDevLog(" DELIVERY HANDLED OK: ${deliveryPacket.messageId.take(8)}...")
} catch (e: Exception) { } catch (e: Exception) {
protocolDevLog(" DELIVERY ERROR: ${e.javaClass.simpleName}: ${e.message}")
markInboundProcessingFailure("Delivery processing failed", e) markInboundProcessingFailure("Delivery processing failed", e)
return@launchInboundPacketTask return@launchInboundPacketTask
} }

View File

@@ -37,18 +37,6 @@ import org.json.JSONObject
*/ */
class ChatViewModel(application: Application) : AndroidViewModel(application) { 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<Application>()
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 { companion object {
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
private const val PAGE_SIZE = 30 private const val PAGE_SIZE = 30
@@ -541,26 +529,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val opponent = opponentKey ?: return@collect val opponent = opponentKey ?: return@collect
val currentDialogKey = getDialogKey(account, opponent) 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) { if (update.dialogKey == currentDialogKey) {
when (update.status) { when (update.status) {
DeliveryStatus.DELIVERED -> { DeliveryStatus.DELIVERED -> {
devLog(" → updateMessageStatus DELIVERED: ${update.messageId.take(8)}...")
updateMessageStatus(update.messageId, MessageStatus.DELIVERED) updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
} }
DeliveryStatus.ERROR -> { DeliveryStatus.ERROR -> {
devLog(" → updateMessageStatus ERROR: ${update.messageId.take(8)}...")
updateMessageStatus(update.messageId, MessageStatus.ERROR) updateMessageStatus(update.messageId, MessageStatus.ERROR)
} }
DeliveryStatus.READ -> { DeliveryStatus.READ -> {
devLog(" → markAllOutgoingAsRead")
markAllOutgoingAsRead() markAllOutgoingAsRead()
} }
else -> {} else -> {}
} }
} else {
devLog(" → SKIPPED (dialog mismatch)")
} }
} }
} }
@@ -620,8 +601,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val currentMessages = _messages.value val currentMessages = _messages.value
val currentStatus = currentMessages.find { it.id == messageId }?.status val currentStatus = currentMessages.find { it.id == messageId }?.status
devLog("updateMessageStatus: msgId=${messageId.take(8)}..., newStatus=$status, currentStatus=$currentStatus, totalMsgs=${currentMessages.size}")
_messages.value = _messages.value =
currentMessages.map { msg -> currentMessages.map { msg ->
if (msg.id != messageId) return@map msg if (msg.id != messageId) return@map msg
@@ -648,10 +627,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
if (mergedStatus != msg.status) { if (mergedStatus != msg.status) {
devLog(" → STATUS CHANGED: ${msg.status}$mergedStatus")
msg.copy(status = mergedStatus) msg.copy(status = mergedStatus)
} else { } else {
devLog(" → STATUS NOT UPGRADED: ${msg.status}$status, skip")
msg msg
} }
} }
@@ -2872,9 +2849,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis() 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 (только первое сообщение) // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Используется для обычного reply (не forward). // Используется для обычного reply (не forward).
val replyData: ReplyData? = val replyData: ReplyData? =
@@ -3136,11 +3110,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер // 📁 Для Saved Messages - НЕ отправляем пакет на сервер
val isSavedMessages = (sender == recipient) val isSavedMessages = (sender == recipient)
if (!isSavedMessages) { if (!isSavedMessages) {
devLog(" SENDING packet: msgId=${messageId.take(8)}..., isConnected=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
ProtocolManager.send(packet) ProtocolManager.send(packet)
devLog(" SENT packet OK: msgId=${messageId.take(8)}...")
} else {
devLog(" Saved Messages — skip send")
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -3209,7 +3179,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(text, timestamp) saveDialog(text, timestamp)
} catch (e: Exception) { } catch (e: Exception) {
devLog(" SEND ERROR: msgId=${messageId.take(8)}..., ${e.javaClass.simpleName}: ${e.message}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR) updateMessageStatus(messageId, MessageStatus.ERROR)
} }
@@ -3430,9 +3399,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachments = finalMessageAttachments attachments = finalMessageAttachments
} }
if (!isSavedMessages) { if (!isSavedMessages) {
devLog(" FWD SENDING: msgId=${messageId.take(8)}..., to=${recipientPublicKey.take(12)}..., isConn=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
ProtocolManager.send(packet) ProtocolManager.send(packet)
devLog(" FWD SENT OK: msgId=${messageId.take(8)}...")
} }
val finalAttachmentsJson = val finalAttachmentsJson =

View File

@@ -286,7 +286,9 @@ fun ChatsListScreen(
onOpenCallOverlay: () -> Unit = {}, onOpenCallOverlay: () -> Unit = {},
onAddAccount: () -> Unit = {}, onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {}, onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {} onDeleteAccountFromSidebar: (String) -> Unit = {},
onQrScanClick: () -> Unit = {},
onMyQrClick: () -> Unit = {}
) { ) {
// Theme transition state // Theme transition state
var hasInitialized by remember { mutableStateOf(false) } 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 // 📖 Saved Messages
DrawerMenuItemEnhanced( DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved), painter = painterResource(id = R.drawable.msg_saved),

View File

@@ -51,11 +51,9 @@ import com.rosetta.messenger.data.MessageRepository
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown import compose.icons.tablericons.ChevronDown
// ── Telegram-style dark gradient colors ────────────────────────── // ── Call colors ──────────────────────────────────────────────────
private val GradientTop = Color(0xFF1A1A2E) private val CallBaseBg = Color(0xFF0D0D14)
private val GradientMid = Color(0xFF16213E)
private val GradientBottom = Color(0xFF0F3460)
private val AcceptGreen = Color(0xFF4CC764) private val AcceptGreen = Color(0xFF4CC764)
private val DeclineRed = Color(0xFFE74C3C) private val DeclineRed = Color(0xFFE74C3C)
private val ButtonBg = Color.White.copy(alpha = 0.15f) 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 RingColor2 = Color.White.copy(alpha = 0.10f)
private val RingColor3 = Color.White.copy(alpha = 0.04f) 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 ───────────────────────────────────────────── // ── Main Call Screen ─────────────────────────────────────────────
@Composable @Composable
@@ -103,12 +219,10 @@ fun CallOverlay(
exit = fadeOut(tween(200)) exit = fadeOut(tween(200))
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.background(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) { ) {
// Animated gradient background (iOS parity)
CallGradientBackground(peerPublicKey = uiState.peerPublicKey)
// ── Top controls: minimize (left) + key cast QR (right) ── // ── Top controls: minimize (left) + key cast QR (right) ──
val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE
val showKeyCast = val showKeyCast =

View File

@@ -977,133 +977,82 @@ private fun ZoomableImage(
* 2) из локального encrypted attachment файла * 2) из локального encrypted attachment файла
* 3) с transport (с последующим сохранением в локальный файл) * 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( private suspend fun loadBitmapForViewerImage(
context: Context, context: Context,
image: ViewableImage, image: ViewableImage,
privateKey: String privateKey: String
): Bitmap? { ): 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 { return try {
// 0. In-memory кэш // 0. In-memory кэш
val cached = ImageBitmapCache.get("img_${image.attachmentId}") val cached = ImageBitmapCache.get("img_${image.attachmentId}")
if (cached != null) { if (cached != null) {
viewerLog(context, " [0] HIT ImageBitmapCache → OK (${cached.width}x${cached.height})")
return cached return cached
} }
viewerLog(context, " [0] MISS ImageBitmapCache")
// 1. Blob в сообщении // 1. Blob в сообщении
if (image.blob.isNotEmpty()) { if (image.blob.isNotEmpty()) {
viewerLog(context, " [1] blob present (${image.blob.length} chars), decoding...")
val bmp = base64ToBitmapSafe(image.blob) val bmp = base64ToBitmapSafe(image.blob)
if (bmp != null) { if (bmp != null) {
viewerLog(context, " [1] blob decode → OK (${bmp.width}x${bmp.height})")
return bmp return bmp
} }
viewerLog(context, " [1] blob decode → FAILED")
} else {
viewerLog(context, " [1] blob empty, skip")
} }
// 2. Локальный encrypted cache // 2. Локальный encrypted cache
viewerLog(context, " [2] readAttachment(id=$idShort, sender=${image.senderPublicKey.take(12)}...)")
val localBlob = val localBlob =
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey) AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
if (localBlob != null) { if (localBlob != null) {
viewerLog(context, " [2] local file found (${localBlob.length} chars), decoding...")
val bmp = base64ToBitmapSafe(localBlob) val bmp = base64ToBitmapSafe(localBlob)
if (bmp != null) { if (bmp != null) {
viewerLog(context, " [2] local decode → OK (${bmp.width}x${bmp.height})")
return bmp return bmp
} }
viewerLog(context, " [2] local decode → FAILED")
} else {
viewerLog(context, " [2] local file NOT found")
} }
// 2.5. Ждём bitmap из кеша // 2.5. Ждём bitmap из кеша
viewerLog(context, " [2.5] awaitCached 3s...")
val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000) val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000)
if (awaitedFromCache != null) { if (awaitedFromCache != null) {
viewerLog(context, " [2.5] await → OK (${awaitedFromCache.width}x${awaitedFromCache.height})")
return awaitedFromCache return awaitedFromCache
} }
viewerLog(context, " [2.5] await → timeout, not found")
// 3. CDN download // 3. CDN download
var downloadTag = getDownloadTag(image.preview) var downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) { if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) {
downloadTag = image.transportTag downloadTag = image.transportTag
} }
viewerLog(context, " [3] downloadTag='${downloadTag.take(16)}...', transportTag='${image.transportTag.take(16)}...', preview='${image.preview.take(30)}...'")
if (downloadTag.isEmpty()) { if (downloadTag.isEmpty()) {
viewerLog(context, " [3] downloadTag EMPTY → FAIL")
return null return null
} }
val server = TransportManager.getTransportServer() ?: "unset"
viewerLog(context, " [3] CDN download: server=$server")
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag) val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes")
if (encryptedContent.isEmpty()) { if (encryptedContent.isEmpty()) {
viewerLog(context, " [3] CDN response EMPTY → FAIL")
return null return null
} }
// Decrypt // Decrypt
val decrypted: String? = val decrypted: String? =
if (image.chachaKeyPlainHex.isNotEmpty()) { 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() val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey) MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() } ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
} else if (image.chachaKey.startsWith("group:")) { } else if (image.chachaKey.startsWith("group:")) {
viewerLog(context, " [3] decrypt via group key")
val groupPassword = CryptoManager.decryptWithPassword( val groupPassword = CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"), privateKey image.chachaKey.removePrefix("group:"), privateKey
) )
if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null
} else if (image.chachaKey.isNotEmpty()) { } else if (image.chachaKey.isNotEmpty()) {
viewerLog(context, " [3] decrypt via chachaKey (${image.chachaKey.length} chars)")
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey) val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
viewerLog(context, " [3] decryptKeyFromSender → keySize=${decryptedKeyAndNonce.size}")
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce) MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
} else { } else {
viewerLog(context, " [3] NO chachaKey available → FAIL")
null null
} }
if (decrypted == null) { if (decrypted == null) {
viewerLog(context, " [3] decrypt → NULL → FAIL")
return null return null
} }
viewerLog(context, " [3] decrypted OK (${decrypted.length} chars)")
val decodedBitmap = base64ToBitmapSafe(decrypted) val decodedBitmap = base64ToBitmapSafe(decrypted)
if (decodedBitmap == null) { if (decodedBitmap == null) {
viewerLog(context, " [3] base64→bitmap → FAILED")
return null return null
} }
viewerLog(context, " [3] bitmap OK (${decodedBitmap.width}x${decodedBitmap.height})")
// Сохраняем локально // Сохраняем локально
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
@@ -1113,10 +1062,8 @@ private suspend fun loadBitmapForViewerImage(
publicKey = image.senderPublicKey, publicKey = image.senderPublicKey,
privateKey = privateKey privateKey = privateKey
) )
viewerLog(context, " saved locally → DONE")
decodedBitmap decodedBitmap
} catch (e: Exception) { } catch (e: Exception) {
viewerLog(context, " EXCEPTION: ${e.javaClass.simpleName}: ${e.message}")
null null
} }
} }

View File

@@ -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<Color>,
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
)
}
}
}
}
}
}

View File

@@ -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()
)
}
}

View File

@@ -84,11 +84,13 @@ fun AppearanceScreen(
) { ) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }

View File

@@ -84,11 +84,13 @@ fun BiometricEnableScreen(
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }

View File

@@ -49,6 +49,17 @@ fun NotificationsScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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() } BackHandler { onBack() }
Column( Column(

View File

@@ -190,7 +190,8 @@ fun OtherProfileScreen(
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
currentUserPrivateKey: String = "", currentUserPrivateKey: String = "",
backgroundBlurColorId: String = "avatar", backgroundBlurColorId: String = "avatar",
onWriteMessage: (SearchUser) -> Unit = {} onWriteMessage: (SearchUser) -> Unit = {},
onCall: (SearchUser) -> Unit = {}
) { ) {
var isBlocked by remember { mutableStateOf(false) } var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
@@ -709,7 +710,7 @@ fun OtherProfileScreen(
if (!isSafetyProfile && !isSystemAccount) { if (!isSafetyProfile && !isSystemAccount) {
// Call // Call
Button( Button(
onClick = { /* TODO: call action */ }, onClick = { onCall(user) },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(48.dp), .height(48.dp),
@@ -1187,7 +1188,8 @@ fun OtherProfileScreen(
avatarTimestamp = avatarViewerTimestamp, avatarTimestamp = avatarViewerTimestamp,
avatarBitmap = avatarViewerBitmap, avatarBitmap = avatarViewerBitmap,
publicKey = user.publicKey, publicKey = user.publicKey,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository
) )
} }
} }

View File

@@ -29,11 +29,13 @@ fun ProfileLogsScreen(
) { ) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }

View File

@@ -284,6 +284,7 @@ fun ProfileScreen(
onNavigateToSafety: () -> Unit = {}, onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {}, onNavigateToLogs: () -> Unit = {},
onNavigateToBiometric: () -> Unit = {}, onNavigateToBiometric: () -> Unit = {},
onNavigateToMyQr: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null, dialogDao: com.rosetta.messenger.database.DialogDao? = null,
@@ -291,11 +292,13 @@ fun ProfileScreen(
) { ) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }
@@ -902,11 +905,20 @@ fun ProfileScreen(
onNavigateToBiometric() onNavigateToBiometric()
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showDivider = false, showDivider = true,
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled" 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)) Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
@@ -1059,7 +1071,8 @@ fun ProfileScreen(
avatarTimestamp = avatarViewerTimestamp, avatarTimestamp = avatarViewerTimestamp,
avatarBitmap = avatarViewerBitmap, avatarBitmap = avatarViewerBitmap,
publicKey = accountPublicKey, publicKey = accountPublicKey,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository
) )
} }
@@ -2024,6 +2037,7 @@ fun ProfileNavigationItem(
// Long-press avatar → full screen with swipe-down to dismiss // Long-press avatar → full screen with swipe-down to dismiss
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
@Composable @Composable
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
fun FullScreenAvatarViewer( fun FullScreenAvatarViewer(
isVisible: Boolean, isVisible: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -2031,23 +2045,57 @@ fun FullScreenAvatarViewer(
avatarTimestamp: Long, avatarTimestamp: Long,
avatarBitmap: android.graphics.Bitmap?, avatarBitmap: android.graphics.Bitmap?,
publicKey: String, publicKey: String,
isDarkTheme: Boolean isDarkTheme: Boolean,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null
) { ) {
val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() } val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
// Animated visibility
var showContent by remember { mutableStateOf(false) } var showContent by remember { mutableStateOf(false) }
LaunchedEffect(isVisible) { LaunchedEffect(isVisible) { showContent = isVisible }
showContent = isVisible
// Load avatar history
var avatarHistory by remember { mutableStateOf(emptyList<com.rosetta.messenger.repository.AvatarInfo>()) }
var decodedBitmaps by remember { mutableStateOf<Map<Int, android.graphics.Bitmap>>(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<Int, android.graphics.Bitmap>()
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 dragOffsetY by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
val dismissThreshold = screenHeight * 0.25f val dismissThreshold = screenHeight * 0.25f
// Animated values
val animatedAlpha by animateFloatAsState( val animatedAlpha by animateFloatAsState(
targetValue = if (showContent) { targetValue = if (showContent) {
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f) val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
@@ -2056,7 +2104,6 @@ fun FullScreenAvatarViewer(
animationSpec = tween(if (showContent && !isDragging) 250 else 200), animationSpec = tween(if (showContent && !isDragging) 250 else 200),
label = "bg_alpha" label = "bg_alpha"
) )
val animatedScale by animateFloatAsState( val animatedScale by animateFloatAsState(
targetValue = if (showContent) { targetValue = if (showContent) {
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f) val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
@@ -2065,29 +2112,24 @@ fun FullScreenAvatarViewer(
animationSpec = tween(if (showContent && !isDragging) 250 else 200), animationSpec = tween(if (showContent && !isDragging) 250 else 200),
label = "scale" label = "scale"
) )
val animatedOffset by animateFloatAsState( 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) animationSpec = if (isDragging) spring(stiffness = Spring.StiffnessHigh)
else spring(dampingRatio = Spring.DampingRatioMediumBouncy), else spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "offset", label = "offset",
finishedListener = { finishedListener = { if (!showContent) onDismiss() }
if (!showContent) onDismiss()
}
) )
// Date formatting fun formatTimestamp(ts: Long): String {
val dateText = remember(avatarTimestamp) { if (ts <= 0) return ""
if (avatarTimestamp > 0) {
val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH) val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH)
val normalizedTimestampMs = when { val ms = when {
avatarTimestamp >= 1_000_000_000_000_000_000L -> avatarTimestamp / 1_000_000 ts >= 1_000_000_000_000_000_000L -> ts / 1_000_000
avatarTimestamp >= 1_000_000_000_000_000L -> avatarTimestamp / 1_000 ts >= 1_000_000_000_000_000L -> ts / 1_000
avatarTimestamp >= 1_000_000_000_000L -> avatarTimestamp ts >= 1_000_000_000_000L -> ts
else -> avatarTimestamp * 1_000 else -> ts * 1_000
} }
sdf.format(Date(normalizedTimestampMs)) return sdf.format(Date(ms))
} else ""
} }
if (isVisible) { if (isVisible) {
@@ -2103,22 +2145,15 @@ fun FullScreenAvatarViewer(
onDragStart = { isDragging = true }, onDragStart = { isDragging = true },
onDragEnd = { onDragEnd = {
isDragging = false isDragging = false
if (kotlin.math.abs(dragOffsetY) > dismissThreshold) { if (kotlin.math.abs(dragOffsetY) > dismissThreshold) showContent = false
showContent = false
}
dragOffsetY = 0f dragOffsetY = 0f
}, },
onDragCancel = { onDragCancel = { isDragging = false; dragOffsetY = 0f },
isDragging = false onVerticalDrag = { _, dragAmount -> dragOffsetY += dragAmount }
dragOffsetY = 0f
},
onVerticalDrag = { _, dragAmount ->
dragOffsetY += dragAmount
}
) )
} }
) { ) {
// Avatar image centered // Avatar pager
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -2129,7 +2164,25 @@ fun FullScreenAvatarViewer(
}, },
contentAlignment = Alignment.Center 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( Image(
bitmap = avatarBitmap.asImageBitmap(), bitmap = avatarBitmap.asImageBitmap(),
contentDescription = "Avatar", contentDescription = "Avatar",
@@ -2139,9 +2192,7 @@ fun FullScreenAvatarViewer(
} else { } else {
val avatarColors = getAvatarColor(publicKey, isDarkTheme) val avatarColors = getAvatarColor(publicKey, isDarkTheme)
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth().aspectRatio(1f)
.fillMaxWidth()
.aspectRatio(1f)
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -2155,81 +2206,60 @@ fun FullScreenAvatarViewer(
} }
} }
// Top gradient for readability // Top gradient
Box( Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.TopCenter)
modifier = Modifier .background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))))
.fillMaxWidth()
.height(120.dp)
.align(Alignment.TopCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Transparent
)
)
)
)
// Bottom gradient shadow // Bottom gradient
Box( Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.BottomCenter)
modifier = Modifier .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)))))
.fillMaxWidth()
.height(120.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.6f)
)
)
)
)
// Header: back button + name + date // Page indicator dots
if (pageCount > 1) {
Row( Row(
modifier = Modifier modifier = Modifier.align(Alignment.TopCenter)
.fillMaxWidth()
.statusBarsPadding() .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()
.padding(horizontal = 4.dp, vertical = 8.dp), .padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { showContent = false }) { IconButton(onClick = { showContent = false }) {
Icon( Icon(imageVector = TablerIcons.ChevronLeft, contentDescription = "Close",
imageVector = TablerIcons.ChevronLeft, tint = Color.White, modifier = Modifier.size(24.dp))
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(text = displayName.ifBlank { publicKey.take(10) },
text = displayName.ifBlank { publicKey.take(10) }, fontSize = 16.sp, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
fontWeight = FontWeight.SemiBold, val dateText = formatTimestamp(currentTimestamp)
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (dateText.isNotEmpty()) { if (dateText.isNotEmpty()) {
Text( Text(text = dateText, fontSize = 13.sp,
text = dateText, color = Color.White.copy(alpha = 0.7f), maxLines = 1)
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f),
maxLines = 1
)
} }
} }
IconButton(onClick = { /* menu */ }) { IconButton(onClick = { /* menu */ }) {
Icon( Icon(painter = TelegramIcons.More, contentDescription = "Menu",
painter = TelegramIcons.More, tint = Color.White, modifier = Modifier.size(24.dp))
contentDescription = "Menu",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
} }
} }
} }

View File

@@ -55,6 +55,17 @@ fun SafetyScreen(
var copiedPrivateKey by remember { mutableStateOf(false) } var copiedPrivateKey by remember { mutableStateOf(false) }
var showDeleteConfirmation 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 // Handle back gesture
BackHandler { onBack() } BackHandler { onBack() }

View File

@@ -75,11 +75,13 @@ fun ThemeScreen(
) { ) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }

View File

@@ -32,11 +32,13 @@ fun UpdatesScreen(
val context = LocalContext.current val context = LocalContext.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { insetsController.isAppearanceLightStatusBars = prev }
} }
} }

View File

@@ -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"
}