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:
@@ -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 и т.д.).
|
||||
|
||||
@@ -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 - кто прочитал (собеседник)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
293
app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
Normal file
293
app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt
Normal file
176
app/src/main/java/com/rosetta/messenger/ui/qr/QrScannerScreen.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user