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:
@@ -207,6 +207,10 @@ dependencies {
|
|||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// QR Code generation (ZXing) + scanning (ML Kit)
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
|
|
||||||
// Testing dependencies
|
// Testing dependencies
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
|
|||||||
@@ -49,9 +49,20 @@
|
|||||||
android:screenOrientation="portrait">
|
android:screenOrientation="portrait">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="rosetta" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="rosetta.im" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -671,6 +671,8 @@ sealed class Screen {
|
|||||||
data object CrashLogs : Screen()
|
data object CrashLogs : Screen()
|
||||||
data object Biometric : Screen()
|
data object Biometric : Screen()
|
||||||
data object Appearance : Screen()
|
data object Appearance : Screen()
|
||||||
|
data object QrScanner : Screen()
|
||||||
|
data object MyQr : Screen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -1028,6 +1030,8 @@ fun MainScreen(
|
|||||||
val isAppearanceVisible by remember {
|
val isAppearanceVisible by remember {
|
||||||
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
||||||
}
|
}
|
||||||
|
val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } }
|
||||||
|
val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
|
||||||
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
|
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
|
||||||
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
|
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
|
||||||
var discardProfileChangesFromChat by remember { mutableStateOf(false) }
|
var discardProfileChangesFromChat by remember { mutableStateOf(false) }
|
||||||
@@ -1240,7 +1244,9 @@ fun MainScreen(
|
|||||||
onAddAccount()
|
onAddAccount()
|
||||||
},
|
},
|
||||||
onSwitchAccount = onSwitchAccount,
|
onSwitchAccount = onSwitchAccount,
|
||||||
onDeleteAccountFromSidebar = onDeleteAccountFromSidebar
|
onDeleteAccountFromSidebar = onDeleteAccountFromSidebar,
|
||||||
|
onQrScanClick = { pushScreen(Screen.QrScanner) },
|
||||||
|
onMyQrClick = { pushScreen(Screen.MyQr) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1304,6 +1310,7 @@ fun MainScreen(
|
|||||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||||
onNavigateToLogs = { pushScreen(Screen.Logs) },
|
onNavigateToLogs = { pushScreen(Screen.Logs) },
|
||||||
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
|
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
|
||||||
|
onNavigateToMyQr = { pushScreen(Screen.MyQr) },
|
||||||
viewModel = profileViewModel,
|
viewModel = profileViewModel,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
||||||
@@ -1542,6 +1549,7 @@ fun MainScreen(
|
|||||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||||
onNavigateToLogs = { pushScreen(Screen.Logs) },
|
onNavigateToLogs = { pushScreen(Screen.Logs) },
|
||||||
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
|
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
|
||||||
|
onNavigateToMyQr = { pushScreen(Screen.MyQr) },
|
||||||
viewModel = profileViewModel,
|
viewModel = profileViewModel,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
||||||
@@ -1710,6 +1718,9 @@ fun MainScreen(
|
|||||||
navStack = navStack.filterNot {
|
navStack = navStack.filterNot {
|
||||||
it is Screen.OtherProfile || it is Screen.ChatDetail
|
it is Screen.OtherProfile || it is Screen.ChatDetail
|
||||||
} + Screen.ChatDetail(chatUser)
|
} + Screen.ChatDetail(chatUser)
|
||||||
|
},
|
||||||
|
onCall = { callableUser ->
|
||||||
|
startCallWithPermission(callableUser)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1788,6 +1799,74 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR Scanner
|
||||||
|
SwipeBackContainer(
|
||||||
|
isVisible = isQrScannerVisible,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } },
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
layer = 3
|
||||||
|
) {
|
||||||
|
com.rosetta.messenger.ui.qr.QrScannerScreen(
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } },
|
||||||
|
onResult = { result ->
|
||||||
|
navStack = navStack.filterNot { it is Screen.QrScanner }
|
||||||
|
when (result.type) {
|
||||||
|
com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> {
|
||||||
|
mainScreenScope.launch {
|
||||||
|
val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000)
|
||||||
|
val user = users.firstOrNull()
|
||||||
|
if (user != null) {
|
||||||
|
pushScreen(Screen.OtherProfile(user))
|
||||||
|
} else {
|
||||||
|
val searchUser = com.rosetta.messenger.network.SearchUser(
|
||||||
|
publicKey = result.payload,
|
||||||
|
title = "", username = "", verified = 0, online = 0
|
||||||
|
)
|
||||||
|
pushScreen(Screen.ChatDetail(searchUser))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.rosetta.messenger.ui.qr.QrResultType.GROUP -> {
|
||||||
|
mainScreenScope.launch {
|
||||||
|
val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context)
|
||||||
|
val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload)
|
||||||
|
if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) {
|
||||||
|
val groupUser = com.rosetta.messenger.network.SearchUser(
|
||||||
|
publicKey = joinResult.dialogPublicKey,
|
||||||
|
title = joinResult.title.ifBlank { "Group" },
|
||||||
|
username = "", verified = 0, online = 0
|
||||||
|
)
|
||||||
|
pushScreen(Screen.ChatDetail(groupUser))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// My QR Code
|
||||||
|
SwipeBackContainer(
|
||||||
|
isVisible = isMyQrVisible,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.MyQr } },
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
layer = 2
|
||||||
|
) {
|
||||||
|
com.rosetta.messenger.ui.qr.MyQrCodeScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
publicKey = accountPublicKey,
|
||||||
|
displayName = accountName,
|
||||||
|
username = accountUsername,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.MyQr } },
|
||||||
|
onScanQr = {
|
||||||
|
navStack = navStack.filterNot { it is Screen.MyQr }
|
||||||
|
pushScreen(Screen.QrScanner)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isCallScreenVisible) {
|
if (isCallScreenVisible) {
|
||||||
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
|
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
|
||||||
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
|
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
|
||||||
|
|||||||
@@ -1009,22 +1009,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Обработка подтверждения доставки */
|
/** Обработка подтверждения доставки */
|
||||||
private fun devLog(msg: String) {
|
|
||||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
|
||||||
val line = "$ts [MsgRepo] $msg"
|
|
||||||
android.util.Log.d("MsgRepo", msg)
|
|
||||||
try {
|
|
||||||
val dir = java.io.File(context.filesDir, "crash_reports")
|
|
||||||
if (!dir.exists()) dir.mkdirs()
|
|
||||||
java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handleDelivery(packet: PacketDelivery) {
|
suspend fun handleDelivery(packet: PacketDelivery) {
|
||||||
val account = currentAccount ?: return
|
val account = currentAccount ?: return
|
||||||
|
|
||||||
devLog("DELIVERY RECEIVED: msgId=${packet.messageId.take(8)}..., to=${packet.toPublicKey.take(12)}...")
|
|
||||||
|
|
||||||
MessageLogger.logDeliveryStatus(
|
MessageLogger.logDeliveryStatus(
|
||||||
messageId = packet.messageId,
|
messageId = packet.messageId,
|
||||||
toPublicKey = packet.toPublicKey,
|
toPublicKey = packet.toPublicKey,
|
||||||
@@ -1055,6 +1042,50 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
|
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an incoming call event locally (for CALLEE side).
|
||||||
|
* Creates a message as if received from the peer, with CALL attachment.
|
||||||
|
*/
|
||||||
|
suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
val privateKey = currentPrivateKey ?: return
|
||||||
|
val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val dialogKey = getDialogKey(fromPublicKey)
|
||||||
|
|
||||||
|
val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
|
||||||
|
val attachmentsJson = org.json.JSONArray().apply {
|
||||||
|
put(org.json.JSONObject().apply {
|
||||||
|
put("id", attId)
|
||||||
|
put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
|
||||||
|
put("preview", durationSec.toString())
|
||||||
|
put("blob", "")
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
|
||||||
|
|
||||||
|
val entity = com.rosetta.messenger.database.MessageEntity(
|
||||||
|
account = account,
|
||||||
|
fromPublicKey = fromPublicKey,
|
||||||
|
toPublicKey = account,
|
||||||
|
content = "",
|
||||||
|
timestamp = timestamp,
|
||||||
|
chachaKey = "",
|
||||||
|
read = 1,
|
||||||
|
fromMe = 0,
|
||||||
|
delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
|
||||||
|
messageId = messageId,
|
||||||
|
plainMessage = encryptedPlainMessage,
|
||||||
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
|
||||||
|
dialogKey = dialogKey
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(entity)
|
||||||
|
dialogDao.updateDialogFromMessages(account, fromPublicKey)
|
||||||
|
_newMessageEvents.tryEmit(dialogKey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
|
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
|
||||||
* fromPublicKey - кто прочитал (собеседник)
|
* fromPublicKey - кто прочитал (собеседник)
|
||||||
|
|||||||
@@ -1020,7 +1020,6 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
|
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
|
||||||
if (role != CallRole.CALLER) return
|
|
||||||
val peerPublicKey = snapshot.peerPublicKey.trim()
|
val peerPublicKey = snapshot.peerPublicKey.trim()
|
||||||
val context = appContext ?: return
|
val context = appContext ?: return
|
||||||
if (peerPublicKey.isBlank()) return
|
if (peerPublicKey.isBlank()) return
|
||||||
@@ -1036,13 +1035,23 @@ object CallManager {
|
|||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
if (role == CallRole.CALLER) {
|
||||||
|
// CALLER: send call attachment as a message (peer will receive it)
|
||||||
MessageRepository.getInstance(context).sendMessage(
|
MessageRepository.getInstance(context).sendMessage(
|
||||||
toPublicKey = peerPublicKey,
|
toPublicKey = peerPublicKey,
|
||||||
text = "",
|
text = "",
|
||||||
attachments = listOf(callAttachment)
|
attachments = listOf(callAttachment)
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// CALLEE: save call event locally (incoming from peer)
|
||||||
|
// CALLER will send their own message which may arrive later
|
||||||
|
MessageRepository.getInstance(context).saveIncomingCallEvent(
|
||||||
|
fromPublicKey = peerPublicKey,
|
||||||
|
durationSec = durationSec
|
||||||
|
)
|
||||||
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Log.w(TAG, "Failed to send call attachment", error)
|
Log.w(TAG, "Failed to emit call attachment", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,6 @@ object ProtocolManager {
|
|||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
private fun protocolDevLog(msg: String) {
|
|
||||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
|
||||||
val line = "$ts [Protocol] $msg"
|
|
||||||
android.util.Log.d("Protocol", msg)
|
|
||||||
try {
|
|
||||||
val ctx = appContext ?: return
|
|
||||||
val dir = java.io.File(ctx.filesDir, "crash_reports")
|
|
||||||
if (!dir.exists()) dir.mkdirs()
|
|
||||||
java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
@Volatile private var packetHandlersRegistered = false
|
@Volatile private var packetHandlersRegistered = false
|
||||||
@Volatile private var stateMonitoringStarted = false
|
@Volatile private var stateMonitoringStarted = false
|
||||||
@Volatile private var syncRequestInFlight = false
|
@Volatile private var syncRequestInFlight = false
|
||||||
@@ -349,12 +338,10 @@ object ProtocolManager {
|
|||||||
// Обработчик доставки (0x08)
|
// Обработчик доставки (0x08)
|
||||||
waitPacket(0x08) { packet ->
|
waitPacket(0x08) { packet ->
|
||||||
val deliveryPacket = packet as PacketDelivery
|
val deliveryPacket = packet as PacketDelivery
|
||||||
protocolDevLog("PACKET 0x08 DELIVERY: msgId=${deliveryPacket.messageId.take(8)}..., to=${deliveryPacket.toPublicKey.take(12)}...")
|
|
||||||
|
|
||||||
launchInboundPacketTask {
|
launchInboundPacketTask {
|
||||||
val repository = messageRepository
|
val repository = messageRepository
|
||||||
if (repository == null || !repository.isInitialized()) {
|
if (repository == null || !repository.isInitialized()) {
|
||||||
protocolDevLog(" DELIVERY SKIPPED: repo not init")
|
|
||||||
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
|
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
|
||||||
markInboundProcessingFailure("Delivery packet skipped before account init")
|
markInboundProcessingFailure("Delivery packet skipped before account init")
|
||||||
return@launchInboundPacketTask
|
return@launchInboundPacketTask
|
||||||
@@ -362,9 +349,7 @@ object ProtocolManager {
|
|||||||
try {
|
try {
|
||||||
repository.handleDelivery(deliveryPacket)
|
repository.handleDelivery(deliveryPacket)
|
||||||
resolveOutgoingRetry(deliveryPacket.messageId)
|
resolveOutgoingRetry(deliveryPacket.messageId)
|
||||||
protocolDevLog(" DELIVERY HANDLED OK: ${deliveryPacket.messageId.take(8)}...")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
protocolDevLog(" DELIVERY ERROR: ${e.javaClass.simpleName}: ${e.message}")
|
|
||||||
markInboundProcessingFailure("Delivery processing failed", e)
|
markInboundProcessingFailure("Delivery processing failed", e)
|
||||||
return@launchInboundPacketTask
|
return@launchInboundPacketTask
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,6 @@ import org.json.JSONObject
|
|||||||
*/
|
*/
|
||||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private fun devLog(msg: String) {
|
|
||||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
|
||||||
val line = "$ts [SendMsg] $msg"
|
|
||||||
android.util.Log.d("SendMsg", msg)
|
|
||||||
try {
|
|
||||||
val ctx = getApplication<Application>()
|
|
||||||
val dir = java.io.File(ctx.filesDir, "crash_reports")
|
|
||||||
if (!dir.exists()) dir.mkdirs()
|
|
||||||
java.io.File(dir, "rosettadev1.txt").appendText("$line\n")
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
private const val PAGE_SIZE = 30
|
private const val PAGE_SIZE = 30
|
||||||
@@ -541,26 +529,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val opponent = opponentKey ?: return@collect
|
val opponent = opponentKey ?: return@collect
|
||||||
val currentDialogKey = getDialogKey(account, opponent)
|
val currentDialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
devLog("DELIVERY EVENT: msgId=${update.messageId.take(8)}..., status=${update.status}, dialogKey=${update.dialogKey.take(20)}..., currentKey=${currentDialogKey.take(20)}..., match=${update.dialogKey == currentDialogKey}")
|
|
||||||
|
|
||||||
if (update.dialogKey == currentDialogKey) {
|
if (update.dialogKey == currentDialogKey) {
|
||||||
when (update.status) {
|
when (update.status) {
|
||||||
DeliveryStatus.DELIVERED -> {
|
DeliveryStatus.DELIVERED -> {
|
||||||
devLog(" → updateMessageStatus DELIVERED: ${update.messageId.take(8)}...")
|
|
||||||
updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
|
updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
|
||||||
}
|
}
|
||||||
DeliveryStatus.ERROR -> {
|
DeliveryStatus.ERROR -> {
|
||||||
devLog(" → updateMessageStatus ERROR: ${update.messageId.take(8)}...")
|
|
||||||
updateMessageStatus(update.messageId, MessageStatus.ERROR)
|
updateMessageStatus(update.messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
DeliveryStatus.READ -> {
|
DeliveryStatus.READ -> {
|
||||||
devLog(" → markAllOutgoingAsRead")
|
|
||||||
markAllOutgoingAsRead()
|
markAllOutgoingAsRead()
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
devLog(" → SKIPPED (dialog mismatch)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,8 +601,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentMessages = _messages.value
|
val currentMessages = _messages.value
|
||||||
val currentStatus = currentMessages.find { it.id == messageId }?.status
|
val currentStatus = currentMessages.find { it.id == messageId }?.status
|
||||||
|
|
||||||
devLog("updateMessageStatus: msgId=${messageId.take(8)}..., newStatus=$status, currentStatus=$currentStatus, totalMsgs=${currentMessages.size}")
|
|
||||||
|
|
||||||
_messages.value =
|
_messages.value =
|
||||||
currentMessages.map { msg ->
|
currentMessages.map { msg ->
|
||||||
if (msg.id != messageId) return@map msg
|
if (msg.id != messageId) return@map msg
|
||||||
@@ -648,10 +627,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mergedStatus != msg.status) {
|
if (mergedStatus != msg.status) {
|
||||||
devLog(" → STATUS CHANGED: ${msg.status} → $mergedStatus")
|
|
||||||
msg.copy(status = mergedStatus)
|
msg.copy(status = mergedStatus)
|
||||||
} else {
|
} else {
|
||||||
devLog(" → STATUS NOT UPGRADED: ${msg.status} ≥ $status, skip")
|
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2872,9 +2849,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
devLog("=== SEND START: msgId=${messageId.take(8)}..., to=${recipient.take(12)}..., text='${text.take(30)}' ===")
|
|
||||||
devLog(" isForward=$isForward, replyMsgs=${replyMsgsToSend.size}, isConnected=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
|
|
||||||
|
|
||||||
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
|
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
|
||||||
// Используется для обычного reply (не forward).
|
// Используется для обычного reply (не forward).
|
||||||
val replyData: ReplyData? =
|
val replyData: ReplyData? =
|
||||||
@@ -3136,11 +3110,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер
|
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер
|
||||||
val isSavedMessages = (sender == recipient)
|
val isSavedMessages = (sender == recipient)
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
devLog(" SENDING packet: msgId=${messageId.take(8)}..., isConnected=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
|
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
devLog(" SENT packet OK: msgId=${messageId.take(8)}...")
|
|
||||||
} else {
|
|
||||||
devLog(" Saved Messages — skip send")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -3209,7 +3179,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
saveDialog(text, timestamp)
|
saveDialog(text, timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
devLog(" SEND ERROR: msgId=${messageId.take(8)}..., ${e.javaClass.simpleName}: ${e.message}")
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
@@ -3430,9 +3399,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachments = finalMessageAttachments
|
attachments = finalMessageAttachments
|
||||||
}
|
}
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
devLog(" FWD SENDING: msgId=${messageId.take(8)}..., to=${recipientPublicKey.take(12)}..., isConn=${ProtocolManager.isConnected()}, isAuth=${ProtocolManager.isAuthenticated()}")
|
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
devLog(" FWD SENT OK: msgId=${messageId.take(8)}...")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val finalAttachmentsJson =
|
val finalAttachmentsJson =
|
||||||
|
|||||||
@@ -286,7 +286,9 @@ fun ChatsListScreen(
|
|||||||
onOpenCallOverlay: () -> Unit = {},
|
onOpenCallOverlay: () -> Unit = {},
|
||||||
onAddAccount: () -> Unit = {},
|
onAddAccount: () -> Unit = {},
|
||||||
onSwitchAccount: (String) -> Unit = {},
|
onSwitchAccount: (String) -> Unit = {},
|
||||||
onDeleteAccountFromSidebar: (String) -> Unit = {}
|
onDeleteAccountFromSidebar: (String) -> Unit = {},
|
||||||
|
onQrScanClick: () -> Unit = {},
|
||||||
|
onMyQrClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// Theme transition state
|
// Theme transition state
|
||||||
var hasInitialized by remember { mutableStateOf(false) }
|
var hasInitialized by remember { mutableStateOf(false) }
|
||||||
@@ -1209,6 +1211,21 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 📷 Scan QR
|
||||||
|
DrawerMenuItemEnhanced(
|
||||||
|
icon = TablerIcons.Scan,
|
||||||
|
text = "Scan QR",
|
||||||
|
iconColor = menuIconColor,
|
||||||
|
textColor = menuTextColor,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
onQrScanClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 📖 Saved Messages
|
// 📖 Saved Messages
|
||||||
DrawerMenuItemEnhanced(
|
DrawerMenuItemEnhanced(
|
||||||
painter = painterResource(id = R.drawable.msg_saved),
|
painter = painterResource(id = R.drawable.msg_saved),
|
||||||
|
|||||||
@@ -51,11 +51,9 @@ import com.rosetta.messenger.data.MessageRepository
|
|||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.ChevronDown
|
import compose.icons.tablericons.ChevronDown
|
||||||
|
|
||||||
// ── Telegram-style dark gradient colors ──────────────────────────
|
// ── Call colors ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private val GradientTop = Color(0xFF1A1A2E)
|
private val CallBaseBg = Color(0xFF0D0D14)
|
||||||
private val GradientMid = Color(0xFF16213E)
|
|
||||||
private val GradientBottom = Color(0xFF0F3460)
|
|
||||||
private val AcceptGreen = Color(0xFF4CC764)
|
private val AcceptGreen = Color(0xFF4CC764)
|
||||||
private val DeclineRed = Color(0xFFE74C3C)
|
private val DeclineRed = Color(0xFFE74C3C)
|
||||||
private val ButtonBg = Color.White.copy(alpha = 0.15f)
|
private val ButtonBg = Color.White.copy(alpha = 0.15f)
|
||||||
@@ -64,6 +62,124 @@ private val RingColor1 = Color.White.copy(alpha = 0.06f)
|
|||||||
private val RingColor2 = Color.White.copy(alpha = 0.10f)
|
private val RingColor2 = Color.White.copy(alpha = 0.10f)
|
||||||
private val RingColor3 = Color.White.copy(alpha = 0.04f)
|
private val RingColor3 = Color.White.copy(alpha = 0.04f)
|
||||||
|
|
||||||
|
// Avatar color palette (Mantine colors like iOS)
|
||||||
|
private val AvatarCallColors = listOf(
|
||||||
|
Color(0xFF228BE6), Color(0xFF15AABF), Color(0xFFBE4BDB),
|
||||||
|
Color(0xFF40C057), Color(0xFF4C6EF5), Color(0xFF82C91E),
|
||||||
|
Color(0xFFFD7E14), Color(0xFFE64980), Color(0xFFFA5252),
|
||||||
|
Color(0xFF12B886), Color(0xFF7950F2)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun callColorForKey(publicKey: String): Color {
|
||||||
|
val hash = publicKey.sumOf { it.code }
|
||||||
|
return AvatarCallColors[hash % AvatarCallColors.size]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Animated gradient background with 3 moving blobs — iOS parity */
|
||||||
|
@Composable
|
||||||
|
private fun CallGradientBackground(peerPublicKey: String) {
|
||||||
|
val primary = remember(peerPublicKey) { callColorForKey(peerPublicKey) }
|
||||||
|
|
||||||
|
// Hue-shifted + darkened variants
|
||||||
|
val blob2Color = remember(primary) {
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(
|
||||||
|
android.graphics.Color.argb(255,
|
||||||
|
(primary.red * 255).toInt(),
|
||||||
|
(primary.green * 255).toInt(),
|
||||||
|
(primary.blue * 255).toInt()
|
||||||
|
), hsv
|
||||||
|
)
|
||||||
|
hsv[0] = (hsv[0] + 25f) % 360f
|
||||||
|
hsv[2] = hsv[2] * 0.7f
|
||||||
|
Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
|
val blob3Color = remember(primary) {
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(
|
||||||
|
android.graphics.Color.argb(255,
|
||||||
|
(primary.red * 255).toInt(),
|
||||||
|
(primary.green * 255).toInt(),
|
||||||
|
(primary.blue * 255).toInt()
|
||||||
|
), hsv
|
||||||
|
)
|
||||||
|
hsv[2] = hsv[2] * 0.27f
|
||||||
|
Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
|
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "callBg")
|
||||||
|
val time by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1000f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1000_000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "time"
|
||||||
|
)
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val maxDim = maxOf(w, h)
|
||||||
|
val t = time
|
||||||
|
|
||||||
|
// Base dark fill
|
||||||
|
drawRect(CallBaseBg)
|
||||||
|
|
||||||
|
// Blob 1 — primary color
|
||||||
|
val x1 = w * 0.5f + w * 0.3f * kotlin.math.sin(t * 0.23f)
|
||||||
|
val y1 = h * 0.35f + h * 0.2f * kotlin.math.sin(t * 0.19f + 1f)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
primary.copy(alpha = 0.55f),
|
||||||
|
primary.copy(alpha = 0.22f),
|
||||||
|
Color.Transparent
|
||||||
|
),
|
||||||
|
center = Offset(x1, y1),
|
||||||
|
radius = maxDim * 0.7f
|
||||||
|
),
|
||||||
|
radius = maxDim * 0.7f,
|
||||||
|
center = Offset(x1, y1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blob 2 — hue shifted
|
||||||
|
val x2 = w * 0.4f + w * 0.35f * kotlin.math.sin(t * 0.17f + 2f)
|
||||||
|
val y2 = h * 0.55f + h * 0.25f * kotlin.math.sin(t * 0.21f + 3.5f)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
blob2Color.copy(alpha = 0.45f),
|
||||||
|
blob2Color.copy(alpha = 0.18f),
|
||||||
|
Color.Transparent
|
||||||
|
),
|
||||||
|
center = Offset(x2, y2),
|
||||||
|
radius = maxDim * 0.65f
|
||||||
|
),
|
||||||
|
radius = maxDim * 0.65f,
|
||||||
|
center = Offset(x2, y2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blob 3 — dark
|
||||||
|
val x3 = w * 0.6f + w * 0.3f * kotlin.math.sin(t * 0.29f + 4f)
|
||||||
|
val y3 = h * 0.7f + h * 0.15f * kotlin.math.sin(t * 0.15f + 5.5f)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
blob3Color.copy(alpha = 0.5f),
|
||||||
|
blob3Color.copy(alpha = 0.2f),
|
||||||
|
Color.Transparent
|
||||||
|
),
|
||||||
|
center = Offset(x3, y3),
|
||||||
|
radius = maxDim * 0.6f
|
||||||
|
),
|
||||||
|
radius = maxDim * 0.6f,
|
||||||
|
center = Offset(x3, y3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Call Screen ─────────────────────────────────────────────
|
// ── Main Call Screen ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -103,12 +219,10 @@ fun CallOverlay(
|
|||||||
exit = fadeOut(tween(200))
|
exit = fadeOut(tween(200))
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
|
// Animated gradient background (iOS parity)
|
||||||
|
CallGradientBackground(peerPublicKey = uiState.peerPublicKey)
|
||||||
// ── Top controls: minimize (left) + key cast QR (right) ──
|
// ── Top controls: minimize (left) + key cast QR (right) ──
|
||||||
val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE
|
val canMinimize = onMinimize != null && uiState.phase != CallPhase.INCOMING && uiState.phase != CallPhase.IDLE
|
||||||
val showKeyCast =
|
val showKeyCast =
|
||||||
|
|||||||
@@ -977,133 +977,82 @@ private fun ZoomableImage(
|
|||||||
* 2) из локального encrypted attachment файла
|
* 2) из локального encrypted attachment файла
|
||||||
* 3) с transport (с последующим сохранением в локальный файл)
|
* 3) с transport (с последующим сохранением в локальный файл)
|
||||||
*/
|
*/
|
||||||
private fun viewerLog(context: Context, msg: String) {
|
|
||||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
|
||||||
val line = "$ts [ViewerImage] $msg"
|
|
||||||
android.util.Log.d("ViewerImage", msg)
|
|
||||||
try {
|
|
||||||
val dir = java.io.File(context.filesDir, "crash_reports")
|
|
||||||
if (!dir.exists()) dir.mkdirs()
|
|
||||||
val f = java.io.File(dir, "rosettadev1.txt")
|
|
||||||
f.appendText("$line\n")
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadBitmapForViewerImage(
|
private suspend fun loadBitmapForViewerImage(
|
||||||
context: Context,
|
context: Context,
|
||||||
image: ViewableImage,
|
image: ViewableImage,
|
||||||
privateKey: String
|
privateKey: String
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
val idShort = if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
|
||||||
viewerLog(context, "=== LOAD START: id=$idShort ===")
|
|
||||||
viewerLog(context, " blob.len=${image.blob.length}, preview.len=${image.preview.length}")
|
|
||||||
viewerLog(context, " chachaKey.len=${image.chachaKey.length}, chachaKeyPlainHex.len=${image.chachaKeyPlainHex.length}")
|
|
||||||
viewerLog(context, " senderPK=${image.senderPublicKey.take(12)}..., privateKey.len=${privateKey.length}")
|
|
||||||
viewerLog(context, " width=${image.width}, height=${image.height}")
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
// 0. In-memory кэш
|
// 0. In-memory кэш
|
||||||
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
|
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
viewerLog(context, " [0] HIT ImageBitmapCache → OK (${cached.width}x${cached.height})")
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
viewerLog(context, " [0] MISS ImageBitmapCache")
|
|
||||||
|
|
||||||
// 1. Blob в сообщении
|
// 1. Blob в сообщении
|
||||||
if (image.blob.isNotEmpty()) {
|
if (image.blob.isNotEmpty()) {
|
||||||
viewerLog(context, " [1] blob present (${image.blob.length} chars), decoding...")
|
|
||||||
val bmp = base64ToBitmapSafe(image.blob)
|
val bmp = base64ToBitmapSafe(image.blob)
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
viewerLog(context, " [1] blob decode → OK (${bmp.width}x${bmp.height})")
|
|
||||||
return bmp
|
return bmp
|
||||||
}
|
}
|
||||||
viewerLog(context, " [1] blob decode → FAILED")
|
|
||||||
} else {
|
|
||||||
viewerLog(context, " [1] blob empty, skip")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Локальный encrypted cache
|
// 2. Локальный encrypted cache
|
||||||
viewerLog(context, " [2] readAttachment(id=$idShort, sender=${image.senderPublicKey.take(12)}...)")
|
|
||||||
val localBlob =
|
val localBlob =
|
||||||
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
|
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
|
||||||
if (localBlob != null) {
|
if (localBlob != null) {
|
||||||
viewerLog(context, " [2] local file found (${localBlob.length} chars), decoding...")
|
|
||||||
val bmp = base64ToBitmapSafe(localBlob)
|
val bmp = base64ToBitmapSafe(localBlob)
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
viewerLog(context, " [2] local decode → OK (${bmp.width}x${bmp.height})")
|
|
||||||
return bmp
|
return bmp
|
||||||
}
|
}
|
||||||
viewerLog(context, " [2] local decode → FAILED")
|
|
||||||
} else {
|
|
||||||
viewerLog(context, " [2] local file NOT found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.5. Ждём bitmap из кеша
|
// 2.5. Ждём bitmap из кеша
|
||||||
viewerLog(context, " [2.5] awaitCached 3s...")
|
|
||||||
val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000)
|
val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000)
|
||||||
if (awaitedFromCache != null) {
|
if (awaitedFromCache != null) {
|
||||||
viewerLog(context, " [2.5] await → OK (${awaitedFromCache.width}x${awaitedFromCache.height})")
|
|
||||||
return awaitedFromCache
|
return awaitedFromCache
|
||||||
}
|
}
|
||||||
viewerLog(context, " [2.5] await → timeout, not found")
|
|
||||||
|
|
||||||
// 3. CDN download
|
// 3. CDN download
|
||||||
var downloadTag = getDownloadTag(image.preview)
|
var downloadTag = getDownloadTag(image.preview)
|
||||||
if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) {
|
if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) {
|
||||||
downloadTag = image.transportTag
|
downloadTag = image.transportTag
|
||||||
}
|
}
|
||||||
viewerLog(context, " [3] downloadTag='${downloadTag.take(16)}...', transportTag='${image.transportTag.take(16)}...', preview='${image.preview.take(30)}...'")
|
|
||||||
if (downloadTag.isEmpty()) {
|
if (downloadTag.isEmpty()) {
|
||||||
viewerLog(context, " [3] downloadTag EMPTY → FAIL")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val server = TransportManager.getTransportServer() ?: "unset"
|
|
||||||
viewerLog(context, " [3] CDN download: server=$server")
|
|
||||||
|
|
||||||
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
||||||
viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes")
|
|
||||||
if (encryptedContent.isEmpty()) {
|
if (encryptedContent.isEmpty()) {
|
||||||
viewerLog(context, " [3] CDN response EMPTY → FAIL")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt
|
// Decrypt
|
||||||
val decrypted: String? =
|
val decrypted: String? =
|
||||||
if (image.chachaKeyPlainHex.isNotEmpty()) {
|
if (image.chachaKeyPlainHex.isNotEmpty()) {
|
||||||
viewerLog(context, " [3] decrypt via chachaKeyPlainHex (${image.chachaKeyPlainHex.length} hex chars)")
|
|
||||||
val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
|
||||||
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
|
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
|
||||||
} else if (image.chachaKey.startsWith("group:")) {
|
} else if (image.chachaKey.startsWith("group:")) {
|
||||||
viewerLog(context, " [3] decrypt via group key")
|
|
||||||
val groupPassword = CryptoManager.decryptWithPassword(
|
val groupPassword = CryptoManager.decryptWithPassword(
|
||||||
image.chachaKey.removePrefix("group:"), privateKey
|
image.chachaKey.removePrefix("group:"), privateKey
|
||||||
)
|
)
|
||||||
if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null
|
if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null
|
||||||
} else if (image.chachaKey.isNotEmpty()) {
|
} else if (image.chachaKey.isNotEmpty()) {
|
||||||
viewerLog(context, " [3] decrypt via chachaKey (${image.chachaKey.length} chars)")
|
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||||
viewerLog(context, " [3] decryptKeyFromSender → keySize=${decryptedKeyAndNonce.size}")
|
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
||||||
} else {
|
} else {
|
||||||
viewerLog(context, " [3] NO chachaKey available → FAIL")
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decrypted == null) {
|
if (decrypted == null) {
|
||||||
viewerLog(context, " [3] decrypt → NULL → FAIL")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
viewerLog(context, " [3] decrypted OK (${decrypted.length} chars)")
|
|
||||||
|
|
||||||
val decodedBitmap = base64ToBitmapSafe(decrypted)
|
val decodedBitmap = base64ToBitmapSafe(decrypted)
|
||||||
if (decodedBitmap == null) {
|
if (decodedBitmap == null) {
|
||||||
viewerLog(context, " [3] base64→bitmap → FAILED")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
viewerLog(context, " [3] bitmap OK (${decodedBitmap.width}x${decodedBitmap.height})")
|
|
||||||
|
|
||||||
// Сохраняем локально
|
// Сохраняем локально
|
||||||
AttachmentFileManager.saveAttachment(
|
AttachmentFileManager.saveAttachment(
|
||||||
@@ -1113,10 +1062,8 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
publicKey = image.senderPublicKey,
|
publicKey = image.senderPublicKey,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
viewerLog(context, " saved locally → DONE")
|
|
||||||
decodedBitmap
|
decodedBitmap
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
viewerLog(context, " EXCEPTION: ${e.javaClass.simpleName}: ${e.message}")
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ fun BiometricEnableScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ fun NotificationsScreen(
|
|||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
androidx.compose.runtime.DisposableEffect(isDarkTheme) {
|
||||||
|
val window = (view.context as android.app.Activity).window
|
||||||
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler { onBack() }
|
BackHandler { onBack() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ fun OtherProfileScreen(
|
|||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
currentUserPrivateKey: String = "",
|
currentUserPrivateKey: String = "",
|
||||||
backgroundBlurColorId: String = "avatar",
|
backgroundBlurColorId: String = "avatar",
|
||||||
onWriteMessage: (SearchUser) -> Unit = {}
|
onWriteMessage: (SearchUser) -> Unit = {},
|
||||||
|
onCall: (SearchUser) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isBlocked by remember { mutableStateOf(false) }
|
var isBlocked by remember { mutableStateOf(false) }
|
||||||
var showAvatarMenu by remember { mutableStateOf(false) }
|
var showAvatarMenu by remember { mutableStateOf(false) }
|
||||||
@@ -709,7 +710,7 @@ fun OtherProfileScreen(
|
|||||||
if (!isSafetyProfile && !isSystemAccount) {
|
if (!isSafetyProfile && !isSystemAccount) {
|
||||||
// Call
|
// Call
|
||||||
Button(
|
Button(
|
||||||
onClick = { /* TODO: call action */ },
|
onClick = { onCall(user) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
@@ -1187,7 +1188,8 @@ fun OtherProfileScreen(
|
|||||||
avatarTimestamp = avatarViewerTimestamp,
|
avatarTimestamp = avatarViewerTimestamp,
|
||||||
avatarBitmap = avatarViewerBitmap,
|
avatarBitmap = avatarViewerBitmap,
|
||||||
publicKey = user.publicKey,
|
publicKey = user.publicKey,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
avatarRepository = avatarRepository
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ fun ProfileLogsScreen(
|
|||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ fun ProfileScreen(
|
|||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
onNavigateToLogs: () -> Unit = {},
|
onNavigateToLogs: () -> Unit = {},
|
||||||
onNavigateToBiometric: () -> Unit = {},
|
onNavigateToBiometric: () -> Unit = {},
|
||||||
|
onNavigateToMyQr: () -> Unit = {},
|
||||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
dialogDao: com.rosetta.messenger.database.DialogDao? = null,
|
dialogDao: com.rosetta.messenger.database.DialogDao? = null,
|
||||||
@@ -291,11 +292,13 @@ fun ProfileScreen(
|
|||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,11 +905,20 @@ fun ProfileScreen(
|
|||||||
onNavigateToBiometric()
|
onNavigateToBiometric()
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showDivider = false,
|
showDivider = true,
|
||||||
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled"
|
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TelegramSettingsItem(
|
||||||
|
icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(compose.icons.TablerIcons.Scan),
|
||||||
|
title = "QR Code",
|
||||||
|
onClick = onNavigateToMyQr,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = false,
|
||||||
|
subtitle = "Share your profile"
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
@@ -1059,7 +1071,8 @@ fun ProfileScreen(
|
|||||||
avatarTimestamp = avatarViewerTimestamp,
|
avatarTimestamp = avatarViewerTimestamp,
|
||||||
avatarBitmap = avatarViewerBitmap,
|
avatarBitmap = avatarViewerBitmap,
|
||||||
publicKey = accountPublicKey,
|
publicKey = accountPublicKey,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
avatarRepository = avatarRepository
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2024,6 +2037,7 @@ fun ProfileNavigationItem(
|
|||||||
// Long-press avatar → full screen with swipe-down to dismiss
|
// Long-press avatar → full screen with swipe-down to dismiss
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
fun FullScreenAvatarViewer(
|
fun FullScreenAvatarViewer(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -2031,23 +2045,57 @@ fun FullScreenAvatarViewer(
|
|||||||
avatarTimestamp: Long,
|
avatarTimestamp: Long,
|
||||||
avatarBitmap: android.graphics.Bitmap?,
|
avatarBitmap: android.graphics.Bitmap?,
|
||||||
publicKey: String,
|
publicKey: String,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean,
|
||||||
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
|
val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
|
||||||
|
|
||||||
// Animated visibility
|
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(isVisible) {
|
LaunchedEffect(isVisible) { showContent = isVisible }
|
||||||
showContent = isVisible
|
|
||||||
|
// Load avatar history
|
||||||
|
var avatarHistory by remember { mutableStateOf(emptyList<com.rosetta.messenger.repository.AvatarInfo>()) }
|
||||||
|
var decodedBitmaps by remember { mutableStateOf<Map<Int, android.graphics.Bitmap>>(emptyMap()) }
|
||||||
|
|
||||||
|
LaunchedEffect(isVisible, publicKey) {
|
||||||
|
if (isVisible && avatarRepository != null) {
|
||||||
|
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
val allAvatars = avatarRepository.getAvatars(publicKey, allDecode = true)
|
||||||
|
val first = allAvatars.value
|
||||||
|
if (first.isNotEmpty()) {
|
||||||
|
avatarHistory = first
|
||||||
|
val bitmaps = mutableMapOf<Int, android.graphics.Bitmap>()
|
||||||
|
first.forEachIndexed { idx, info ->
|
||||||
|
try {
|
||||||
|
val raw = info.base64Data
|
||||||
|
val base64 = if (raw.contains(",")) raw.substringAfter(",") else raw
|
||||||
|
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||||
|
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let {
|
||||||
|
bitmaps[idx] = it
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
decodedBitmaps = bitmaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe-to-dismiss offset
|
val pageCount = if (avatarHistory.isNotEmpty()) avatarHistory.size else 1
|
||||||
|
val pagerState = androidx.compose.foundation.pager.rememberPagerState(pageCount = { pageCount })
|
||||||
|
|
||||||
|
// Current page timestamp
|
||||||
|
val currentTimestamp = if (avatarHistory.isNotEmpty() && pagerState.currentPage in avatarHistory.indices) {
|
||||||
|
avatarHistory[pagerState.currentPage].timestamp
|
||||||
|
} else avatarTimestamp
|
||||||
|
|
||||||
|
// Swipe-to-dismiss
|
||||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
val dismissThreshold = screenHeight * 0.25f
|
val dismissThreshold = screenHeight * 0.25f
|
||||||
|
|
||||||
// Animated values
|
|
||||||
val animatedAlpha by animateFloatAsState(
|
val animatedAlpha by animateFloatAsState(
|
||||||
targetValue = if (showContent) {
|
targetValue = if (showContent) {
|
||||||
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
||||||
@@ -2056,7 +2104,6 @@ fun FullScreenAvatarViewer(
|
|||||||
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
||||||
label = "bg_alpha"
|
label = "bg_alpha"
|
||||||
)
|
)
|
||||||
|
|
||||||
val animatedScale by animateFloatAsState(
|
val animatedScale by animateFloatAsState(
|
||||||
targetValue = if (showContent) {
|
targetValue = if (showContent) {
|
||||||
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
||||||
@@ -2065,29 +2112,24 @@ fun FullScreenAvatarViewer(
|
|||||||
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
||||||
label = "scale"
|
label = "scale"
|
||||||
)
|
)
|
||||||
|
|
||||||
val animatedOffset by animateFloatAsState(
|
val animatedOffset by animateFloatAsState(
|
||||||
targetValue = if (isDragging) dragOffsetY else if (showContent) 0f else 0f,
|
targetValue = if (isDragging) dragOffsetY else 0f,
|
||||||
animationSpec = if (isDragging) spring(stiffness = Spring.StiffnessHigh)
|
animationSpec = if (isDragging) spring(stiffness = Spring.StiffnessHigh)
|
||||||
else spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
else spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
label = "offset",
|
label = "offset",
|
||||||
finishedListener = {
|
finishedListener = { if (!showContent) onDismiss() }
|
||||||
if (!showContent) onDismiss()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Date formatting
|
fun formatTimestamp(ts: Long): String {
|
||||||
val dateText = remember(avatarTimestamp) {
|
if (ts <= 0) return ""
|
||||||
if (avatarTimestamp > 0) {
|
|
||||||
val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH)
|
val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH)
|
||||||
val normalizedTimestampMs = when {
|
val ms = when {
|
||||||
avatarTimestamp >= 1_000_000_000_000_000_000L -> avatarTimestamp / 1_000_000
|
ts >= 1_000_000_000_000_000_000L -> ts / 1_000_000
|
||||||
avatarTimestamp >= 1_000_000_000_000_000L -> avatarTimestamp / 1_000
|
ts >= 1_000_000_000_000_000L -> ts / 1_000
|
||||||
avatarTimestamp >= 1_000_000_000_000L -> avatarTimestamp
|
ts >= 1_000_000_000_000L -> ts
|
||||||
else -> avatarTimestamp * 1_000
|
else -> ts * 1_000
|
||||||
}
|
}
|
||||||
sdf.format(Date(normalizedTimestampMs))
|
return sdf.format(Date(ms))
|
||||||
} else ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
@@ -2103,22 +2145,15 @@ fun FullScreenAvatarViewer(
|
|||||||
onDragStart = { isDragging = true },
|
onDragStart = { isDragging = true },
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
isDragging = false
|
isDragging = false
|
||||||
if (kotlin.math.abs(dragOffsetY) > dismissThreshold) {
|
if (kotlin.math.abs(dragOffsetY) > dismissThreshold) showContent = false
|
||||||
showContent = false
|
|
||||||
}
|
|
||||||
dragOffsetY = 0f
|
dragOffsetY = 0f
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = { isDragging = false; dragOffsetY = 0f },
|
||||||
isDragging = false
|
onVerticalDrag = { _, dragAmount -> dragOffsetY += dragAmount }
|
||||||
dragOffsetY = 0f
|
|
||||||
},
|
|
||||||
onVerticalDrag = { _, dragAmount ->
|
|
||||||
dragOffsetY += dragAmount
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Avatar image centered
|
// Avatar pager
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -2129,7 +2164,25 @@ fun FullScreenAvatarViewer(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (avatarBitmap != null) {
|
if (avatarHistory.size > 1) {
|
||||||
|
androidx.compose.foundation.pager.HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
key = { it }
|
||||||
|
) { page ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
val bmp = decodedBitmaps[page]
|
||||||
|
if (bmp != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "Avatar ${page + 1}",
|
||||||
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (avatarBitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = avatarBitmap.asImageBitmap(),
|
bitmap = avatarBitmap.asImageBitmap(),
|
||||||
contentDescription = "Avatar",
|
contentDescription = "Avatar",
|
||||||
@@ -2139,9 +2192,7 @@ fun FullScreenAvatarViewer(
|
|||||||
} else {
|
} else {
|
||||||
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.background(avatarColors.backgroundColor),
|
.background(avatarColors.backgroundColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -2155,81 +2206,60 @@ fun FullScreenAvatarViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top gradient for readability
|
// Top gradient
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.TopCenter)
|
||||||
modifier = Modifier
|
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))))
|
||||||
.fillMaxWidth()
|
|
||||||
.height(120.dp)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Black.copy(alpha = 0.6f),
|
|
||||||
Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bottom gradient shadow
|
// Bottom gradient
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxWidth().height(120.dp).align(Alignment.BottomCenter)
|
||||||
modifier = Modifier
|
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)))))
|
||||||
.fillMaxWidth()
|
|
||||||
.height(120.dp)
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Header: back button + name + date
|
// Page indicator dots
|
||||||
|
if (pageCount > 1) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
.fillMaxWidth()
|
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
|
.padding(top = 56.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
repeat(pageCount) { idx ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(2.dp)
|
||||||
|
.background(
|
||||||
|
if (idx == pagerState.currentPage) Color.White
|
||||||
|
else Color.White.copy(alpha = 0.35f),
|
||||||
|
RoundedCornerShape(1.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().statusBarsPadding()
|
||||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { showContent = false }) {
|
IconButton(onClick = { showContent = false }) {
|
||||||
Icon(
|
Icon(imageVector = TablerIcons.ChevronLeft, contentDescription = "Close",
|
||||||
imageVector = TablerIcons.ChevronLeft,
|
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||||
contentDescription = "Close",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(text = displayName.ifBlank { publicKey.take(10) },
|
||||||
text = displayName.ifBlank { publicKey.take(10) },
|
fontSize = 16.sp, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 16.sp,
|
color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
fontWeight = FontWeight.SemiBold,
|
val dateText = formatTimestamp(currentTimestamp)
|
||||||
color = Color.White,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (dateText.isNotEmpty()) {
|
if (dateText.isNotEmpty()) {
|
||||||
Text(
|
Text(text = dateText, fontSize = 13.sp,
|
||||||
text = dateText,
|
color = Color.White.copy(alpha = 0.7f), maxLines = 1)
|
||||||
fontSize = 13.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = { /* menu */ }) {
|
IconButton(onClick = { /* menu */ }) {
|
||||||
Icon(
|
Icon(painter = TelegramIcons.More, contentDescription = "Menu",
|
||||||
painter = TelegramIcons.More,
|
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||||
contentDescription = "Menu",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ fun SafetyScreen(
|
|||||||
var copiedPrivateKey by remember { mutableStateOf(false) }
|
var copiedPrivateKey by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
androidx.compose.runtime.DisposableEffect(isDarkTheme) {
|
||||||
|
val window = (view.context as android.app.Activity).window
|
||||||
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle back gesture
|
// Handle back gesture
|
||||||
BackHandler { onBack() }
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ fun ThemeScreen(
|
|||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ fun UpdatesScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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