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

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

View File

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

View File

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

View File

@@ -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<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 {
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 =

View File

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

View File

@@ -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 =

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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