feat: Enhance search functionality and user experience
- Added local account metadata handling in SearchScreen for improved "Saved Messages" search fallback. - Updated search logic to include username and account name checks when searching for the user. - Introduced search logging in SearchUsersViewModel for better debugging and tracking of search queries. - Refactored image download process in AttachmentComponents to include detailed logging for debugging. - Created AttachmentDownloadDebugLogger to manage and display download logs. - Improved DeviceVerificationBanner UI for better user engagement during device verification. - Adjusted OtherProfileScreen layout to enhance information visibility and user interaction. - Updated network security configuration to include new Let's Encrypt certificate for CDN.
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.0.2"
|
val rosettaVersionName = "1.0.3"
|
||||||
val rosettaVersionCode = 2 // Increment on each release
|
val rosettaVersionCode = 3 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import com.google.firebase.FirebaseApp
|
|||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
import com.rosetta.messenger.data.PreferencesManager
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.PacketPushNotification
|
import com.rosetta.messenger.network.PacketPushNotification
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
@@ -158,31 +160,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
accountManager.logout() // Always start logged out
|
accountManager.logout() // Always start logged out
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
hasExistingAccount = accounts.isNotEmpty()
|
hasExistingAccount = accounts.isNotEmpty()
|
||||||
accountInfoList =
|
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||||
accounts.map { account ->
|
|
||||||
val shortKey = account.publicKey.take(7)
|
|
||||||
val displayName = account.name ?: shortKey
|
|
||||||
val initials =
|
|
||||||
displayName
|
|
||||||
.trim()
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.let { words ->
|
|
||||||
when {
|
|
||||||
words.isEmpty() -> "??"
|
|
||||||
words.size == 1 -> words[0].take(2).uppercase()
|
|
||||||
else ->
|
|
||||||
"${words[0].first()}${words[1].first()}".uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountInfo(
|
|
||||||
id = account.publicKey,
|
|
||||||
name = displayName,
|
|
||||||
username = account.username ?: "",
|
|
||||||
initials = initials,
|
|
||||||
publicKey = account.publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for initial load
|
// Wait for initial load
|
||||||
@@ -197,6 +175,15 @@ class MainActivity : FragmentActivity() {
|
|||||||
return@setContent
|
return@setContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure push token subscription is sent whenever protocol reaches AUTHENTICATED.
|
||||||
|
// This recovers token binding after reconnects and delayed handshakes.
|
||||||
|
LaunchedEffect(protocolState, currentAccount?.publicKey) {
|
||||||
|
currentAccount ?: return@LaunchedEffect
|
||||||
|
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||||
|
sendFcmTokenToServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) {
|
RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -212,6 +199,10 @@ class MainActivity : FragmentActivity() {
|
|||||||
"auth_new"
|
"auth_new"
|
||||||
isLoggedIn != true && hasExistingAccount == true ->
|
isLoggedIn != true && hasExistingAccount == true ->
|
||||||
"auth_unlock"
|
"auth_unlock"
|
||||||
|
isLoggedIn == true &&
|
||||||
|
currentAccount == null &&
|
||||||
|
hasExistingAccount == true ->
|
||||||
|
"auth_unlock"
|
||||||
protocolState ==
|
protocolState ==
|
||||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
|
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
|
||||||
"device_confirm"
|
"device_confirm"
|
||||||
@@ -261,45 +252,12 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
// 📤 Отправляем FCM токен на сервер после успешной
|
// 📤 Отправляем FCM токен на сервер после успешной
|
||||||
// аутентификации
|
// аутентификации
|
||||||
account?.let { sendFcmTokenToServer(it) }
|
account?.let { sendFcmTokenToServer() }
|
||||||
|
|
||||||
// Reload accounts list
|
// Reload accounts list
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
accountInfoList =
|
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||||
accounts.map { acc ->
|
|
||||||
val shortKey = acc.publicKey.take(7)
|
|
||||||
val displayName = acc.name ?: shortKey
|
|
||||||
val initials =
|
|
||||||
displayName
|
|
||||||
.trim()
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.filter {
|
|
||||||
it.isNotEmpty()
|
|
||||||
}
|
|
||||||
.let { words ->
|
|
||||||
when {
|
|
||||||
words.isEmpty() ->
|
|
||||||
"??"
|
|
||||||
words.size ==
|
|
||||||
1 ->
|
|
||||||
words[0]
|
|
||||||
.take(
|
|
||||||
2
|
|
||||||
)
|
|
||||||
.uppercase()
|
|
||||||
else ->
|
|
||||||
"${words[0].first()}${words[1].first()}".uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountInfo(
|
|
||||||
id = acc.publicKey,
|
|
||||||
name = displayName,
|
|
||||||
username = acc.username ?: "",
|
|
||||||
initials = initials,
|
|
||||||
publicKey = acc.publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
@@ -359,27 +317,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
accountManager.deleteAccount(publicKey)
|
accountManager.deleteAccount(publicKey)
|
||||||
// 8. Refresh accounts list
|
// 8. Refresh accounts list
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
accountInfoList = accounts.map { acc ->
|
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||||
val shortKey = acc.publicKey.take(7)
|
|
||||||
val displayName = acc.name ?: shortKey
|
|
||||||
val initials = displayName.trim()
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.let { words ->
|
|
||||||
when {
|
|
||||||
words.isEmpty() -> "??"
|
|
||||||
words.size == 1 -> words[0].take(2).uppercase()
|
|
||||||
else -> "${words[0].first()}${words[1].first()}".uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountInfo(
|
|
||||||
id = acc.publicKey,
|
|
||||||
name = displayName,
|
|
||||||
username = acc.username ?: "",
|
|
||||||
initials = initials,
|
|
||||||
publicKey = acc.publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
hasExistingAccount = accounts.isNotEmpty()
|
hasExistingAccount = accounts.isNotEmpty()
|
||||||
// 8. Navigate away last
|
// 8. Navigate away last
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
@@ -391,37 +329,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
onAccountInfoUpdated = {
|
onAccountInfoUpdated = {
|
||||||
// Reload account list when profile is updated
|
// Reload account list when profile is updated
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
accountInfoList =
|
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||||
accounts.map { acc ->
|
|
||||||
val shortKey = acc.publicKey.take(7)
|
|
||||||
val displayName = acc.name ?: shortKey
|
|
||||||
val initials =
|
|
||||||
displayName
|
|
||||||
.trim()
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.let { words ->
|
|
||||||
when {
|
|
||||||
words.isEmpty() ->
|
|
||||||
"??"
|
|
||||||
words.size == 1 ->
|
|
||||||
words[0]
|
|
||||||
.take(
|
|
||||||
2
|
|
||||||
)
|
|
||||||
.uppercase()
|
|
||||||
else ->
|
|
||||||
"${words[0].first()}${words[1].first()}".uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountInfo(
|
|
||||||
id = acc.publicKey,
|
|
||||||
name = displayName,
|
|
||||||
username = acc.username ?: "",
|
|
||||||
initials = initials,
|
|
||||||
publicKey = acc.publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSwitchAccount = { targetPublicKey ->
|
onSwitchAccount = { targetPublicKey ->
|
||||||
// Switch to another account: logout current, then auto-login target
|
// Switch to another account: logout current, then auto-login target
|
||||||
@@ -491,6 +399,11 @@ class MainActivity : FragmentActivity() {
|
|||||||
// Сохраняем токен локально
|
// Сохраняем токен локально
|
||||||
saveFcmToken(token)
|
saveFcmToken(token)
|
||||||
addFcmLog("💾 Токен сохранен локально")
|
addFcmLog("💾 Токен сохранен локально")
|
||||||
|
|
||||||
|
if (ProtocolManager.state.value == ProtocolState.AUTHENTICATED) {
|
||||||
|
addFcmLog("🔁 Протокол уже AUTHENTICATED, отправляем токен сразу")
|
||||||
|
sendFcmTokenToServer()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addFcmLog("⚠️ Токен пустой")
|
addFcmLog("⚠️ Токен пустой")
|
||||||
}
|
}
|
||||||
@@ -514,7 +427,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
* Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже
|
* Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже
|
||||||
* расшифрован
|
* расшифрован
|
||||||
*/
|
*/
|
||||||
private fun sendFcmTokenToServer(account: DecryptedAccount) {
|
private fun sendFcmTokenToServer() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE)
|
||||||
@@ -558,6 +471,30 @@ class MainActivity : FragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildInitials(displayName: String): String =
|
||||||
|
displayName
|
||||||
|
.trim()
|
||||||
|
.split(Regex("\\s+"))
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.let { words ->
|
||||||
|
when {
|
||||||
|
words.isEmpty() -> "??"
|
||||||
|
words.size == 1 -> words[0].take(2).uppercase()
|
||||||
|
else -> "${words[0].first()}${words[1].first()}".uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EncryptedAccount.toAccountInfo(): AccountInfo {
|
||||||
|
val displayName = resolveAccountDisplayName(publicKey, name, username)
|
||||||
|
return AccountInfo(
|
||||||
|
id = publicKey,
|
||||||
|
name = displayName,
|
||||||
|
username = username ?: "",
|
||||||
|
initials = buildInitials(displayName),
|
||||||
|
publicKey = publicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList
|
* Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList
|
||||||
* is always the base layer and is not part of the stack. Each SwipeBackContainer reads a
|
* is always the base layer and is not part of the stack. Each SwipeBackContainer reads a
|
||||||
@@ -592,14 +529,17 @@ fun MainScreen(
|
|||||||
onAccountInfoUpdated: suspend () -> Unit = {},
|
onAccountInfoUpdated: suspend () -> Unit = {},
|
||||||
onSwitchAccount: (String) -> Unit = {}
|
onSwitchAccount: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val accountPublicKey = account?.publicKey.orEmpty()
|
||||||
|
|
||||||
// Reactive state for account name and username
|
// Reactive state for account name and username
|
||||||
var accountName by remember { mutableStateOf(account?.name ?: "Account") }
|
var accountName by remember(accountPublicKey) {
|
||||||
|
mutableStateOf(resolveAccountDisplayName(accountPublicKey, account?.name, null))
|
||||||
|
}
|
||||||
val accountPhone =
|
val accountPhone =
|
||||||
account?.publicKey?.take(16)?.let {
|
account?.publicKey?.take(16)?.let {
|
||||||
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
|
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
|
||||||
}
|
}
|
||||||
?: "+7 775 9932587"
|
.orEmpty()
|
||||||
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
|
|
||||||
val accountPrivateKey = account?.privateKey ?: ""
|
val accountPrivateKey = account?.privateKey ?: ""
|
||||||
val privateKeyHash = account?.privateKeyHash ?: ""
|
val privateKeyHash = account?.privateKeyHash ?: ""
|
||||||
|
|
||||||
@@ -611,11 +551,17 @@ fun MainScreen(
|
|||||||
// Load username AND name from AccountManager (persisted in DataStore)
|
// Load username AND name from AccountManager (persisted in DataStore)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||||
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
||||||
accountUsername = encryptedAccount?.username ?: ""
|
val username = encryptedAccount?.username
|
||||||
accountName = encryptedAccount?.name ?: accountName
|
accountUsername = username.orEmpty()
|
||||||
|
accountName =
|
||||||
|
resolveAccountDisplayName(
|
||||||
|
accountPublicKey,
|
||||||
|
encryptedAccount?.name ?: accountName,
|
||||||
|
username
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,14 +571,17 @@ fun MainScreen(
|
|||||||
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
|
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
|
||||||
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
|
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
|
||||||
LaunchedEffect(ownProfileUpdated) {
|
LaunchedEffect(ownProfileUpdated) {
|
||||||
if (ownProfileUpdated > 0L &&
|
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
|
||||||
accountPublicKey.isNotBlank() &&
|
|
||||||
accountPublicKey != "04c266b98ae5"
|
|
||||||
) {
|
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
||||||
accountUsername = encryptedAccount?.username ?: ""
|
val username = encryptedAccount?.username
|
||||||
accountName = encryptedAccount?.name ?: accountName
|
accountUsername = username.orEmpty()
|
||||||
|
accountName =
|
||||||
|
resolveAccountDisplayName(
|
||||||
|
accountPublicKey,
|
||||||
|
encryptedAccount?.name ?: accountName,
|
||||||
|
username
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +664,7 @@ fun MainScreen(
|
|||||||
// AvatarRepository для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
val avatarRepository =
|
val avatarRepository =
|
||||||
remember(accountPublicKey) {
|
remember(accountPublicKey) {
|
||||||
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val database = RosettaDatabase.getDatabase(context)
|
val database = RosettaDatabase.getDatabase(context)
|
||||||
AvatarRepository(
|
AvatarRepository(
|
||||||
context = context,
|
context = context,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ object MessageCrypto {
|
|||||||
private const val CHACHA_KEY_SIZE = 32
|
private const val CHACHA_KEY_SIZE = 32
|
||||||
private const val XCHACHA_NONCE_SIZE = 24
|
private const val XCHACHA_NONCE_SIZE = 24
|
||||||
private const val POLY1305_TAG_SIZE = 16
|
private const val POLY1305_TAG_SIZE = 16
|
||||||
|
private const val SYNC_KEY_PREFIX = "sync:"
|
||||||
|
|
||||||
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes
|
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes
|
||||||
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
|
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
|
||||||
@@ -387,6 +388,16 @@ object MessageCrypto {
|
|||||||
* КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину!
|
* КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину!
|
||||||
*/
|
*/
|
||||||
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
||||||
|
if (encryptedKeyBase64.startsWith(SYNC_KEY_PREFIX)) {
|
||||||
|
val aesChachaKey = encryptedKeyBase64.removePrefix(SYNC_KEY_PREFIX)
|
||||||
|
if (aesChachaKey.isBlank()) {
|
||||||
|
throw IllegalArgumentException("Invalid sync key format: empty aesChachaKey")
|
||||||
|
}
|
||||||
|
val decoded =
|
||||||
|
CryptoManager.decryptWithPassword(aesChachaKey, myPrivateKeyHex)
|
||||||
|
?: throw IllegalArgumentException("Failed to decrypt sync chacha key")
|
||||||
|
return decoded.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
}
|
||||||
|
|
||||||
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))
|
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.rosetta.messenger.data
|
||||||
|
|
||||||
|
private const val PLACEHOLDER_ACCOUNT_NAME = "Account"
|
||||||
|
|
||||||
|
fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String {
|
||||||
|
val normalizedName = name?.trim().orEmpty()
|
||||||
|
if (normalizedName.isNotEmpty() && !normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)) {
|
||||||
|
return normalizedName
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedUsername = username?.trim().orEmpty()
|
||||||
|
if (normalizedUsername.isNotEmpty()) {
|
||||||
|
return normalizedUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey.isBlank()) {
|
||||||
|
return PLACEHOLDER_ACCOUNT_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (publicKey.length > 12) {
|
||||||
|
"${publicKey.take(6)}...${publicKey.takeLast(4)}"
|
||||||
|
} else {
|
||||||
|
publicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPlaceholderAccountName(name: String?): Boolean =
|
||||||
|
name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)
|
||||||
@@ -612,6 +612,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||||
|
|
||||||
|
val storedChachaKey =
|
||||||
|
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||||
|
"sync:${packet.aesChachaKey}"
|
||||||
|
} else {
|
||||||
|
packet.chachaKey
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем entity для кэша и возможной вставки
|
// Создаем entity для кэша и возможной вставки
|
||||||
val entity =
|
val entity =
|
||||||
MessageEntity(
|
MessageEntity(
|
||||||
@@ -620,7 +627,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
toPublicKey = packet.toPublicKey,
|
toPublicKey = packet.toPublicKey,
|
||||||
content = packet.content,
|
content = packet.content,
|
||||||
timestamp = packet.timestamp,
|
timestamp = packet.timestamp,
|
||||||
chachaKey = packet.chachaKey,
|
chachaKey = storedChachaKey,
|
||||||
read = 0,
|
read = 0,
|
||||||
fromMe = if (isOwnMessage) 1 else 0,
|
fromMe = if (isOwnMessage) 1 else 0,
|
||||||
delivered = DeliveryStatus.DELIVERED.value,
|
delivered = DeliveryStatus.DELIVERED.value,
|
||||||
@@ -711,27 +718,37 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
suspend fun handleRead(packet: PacketRead) {
|
suspend fun handleRead(packet: PacketRead) {
|
||||||
val account = currentAccount ?: return
|
val account = currentAccount ?: return
|
||||||
|
val fromPublicKey = packet.fromPublicKey.trim()
|
||||||
|
val toPublicKey = packet.toPublicKey.trim()
|
||||||
|
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return
|
||||||
|
|
||||||
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
|
MessageLogger.debug("👁 READ PACKET from=${fromPublicKey.take(16)}..., to=${toPublicKey.take(16)}...")
|
||||||
|
|
||||||
// Синхронизация read может прийти как:
|
// Desktop parity:
|
||||||
// 1) from=opponent, to=account (обычный read от собеседника)
|
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
|
||||||
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта)
|
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
|
||||||
val opponentKey =
|
val isOwnReadSync = fromPublicKey == account
|
||||||
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey
|
val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
|
||||||
if (opponentKey.isBlank()) return
|
if (opponentKey.isBlank()) return
|
||||||
|
|
||||||
// Проверяем последнее сообщение ДО обновления
|
val dialogKey = getDialogKey(opponentKey)
|
||||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
|
|
||||||
|
|
||||||
// Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные
|
if (isOwnReadSync) {
|
||||||
|
// Sync read from another own device: mark incoming messages as read.
|
||||||
|
messageDao.markDialogAsRead(account, dialogKey)
|
||||||
|
messageCache[dialogKey]?.let { flow ->
|
||||||
|
flow.value =
|
||||||
|
flow.value.map { msg ->
|
||||||
|
if (!msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opponent read our outgoing messages.
|
||||||
messageDao.markAllAsRead(account, opponentKey)
|
messageDao.markAllAsRead(account, opponentKey)
|
||||||
|
|
||||||
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
|
|
||||||
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
|
|
||||||
|
|
||||||
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
|
|
||||||
val dialogKey = getDialogKey(opponentKey)
|
|
||||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value =
|
flow.value =
|
||||||
@@ -740,17 +757,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения)
|
// Notify current dialog UI: all outgoing messages are now read.
|
||||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||||
|
|
||||||
// 📝 LOG: Статус прочтения
|
|
||||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
|
||||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||||
|
|
||||||
// Логируем что записалось в диалог
|
|
||||||
val dialog = dialogDao.getDialog(account, opponentKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ class PacketSearch : Packet() {
|
|||||||
stream.writeInt16(getPacketId())
|
stream.writeInt16(getPacketId())
|
||||||
stream.writeString(privateKey)
|
stream.writeString(privateKey)
|
||||||
stream.writeString(search)
|
stream.writeString(search)
|
||||||
|
// Desktop parity: packet always includes users count block.
|
||||||
|
// For client requests this is 0, but field must be present for server parser.
|
||||||
|
stream.writeInt16(users.size)
|
||||||
|
users.forEach { user ->
|
||||||
|
stream.writeString(user.username)
|
||||||
|
stream.writeString(user.title)
|
||||||
|
stream.writeString(user.publicKey)
|
||||||
|
stream.writeInt8(user.verified)
|
||||||
|
stream.writeInt8(user.online)
|
||||||
|
}
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -277,7 +278,7 @@ object ProtocolManager {
|
|||||||
// Если это наш own profile — сохраняем username/name в AccountManager
|
// Если это наш own profile — сохраняем username/name в AccountManager
|
||||||
if (user.publicKey == ownPublicKey && appContext != null) {
|
if (user.publicKey == ownPublicKey && appContext != null) {
|
||||||
val accountManager = AccountManager(appContext!!)
|
val accountManager = AccountManager(appContext!!)
|
||||||
if (user.title.isNotBlank()) {
|
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
|
||||||
accountManager.updateAccountName(user.publicKey, user.title)
|
accountManager.updateAccountName(user.publicKey, user.title)
|
||||||
}
|
}
|
||||||
if (user.username.isNotBlank()) {
|
if (user.username.isNotBlank()) {
|
||||||
@@ -333,6 +334,24 @@ object ProtocolManager {
|
|||||||
TransportManager.requestTransportServer()
|
TransportManager.requestTransportServer()
|
||||||
fetchOwnProfile()
|
fetchOwnProfile()
|
||||||
requestSynchronize()
|
requestSynchronize()
|
||||||
|
subscribePushTokenIfAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subscribePushTokenIfAvailable() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
val token =
|
||||||
|
context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
|
||||||
|
.getString("fcm_token", null)
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
if (token.isEmpty()) return
|
||||||
|
|
||||||
|
val packet = PacketPushNotification().apply {
|
||||||
|
notificationsToken = token
|
||||||
|
action = PushNotificationAction.SUBSCRIBE
|
||||||
|
}
|
||||||
|
send(packet)
|
||||||
|
addLog("🔔 Push token subscribe requested on AUTHENTICATED")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestSynchronize() {
|
private fun requestSynchronize() {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -29,12 +28,8 @@ data class TransportState(
|
|||||||
* Совместимо с desktop версией (TransportProvider)
|
* Совместимо с desktop версией (TransportProvider)
|
||||||
*/
|
*/
|
||||||
object TransportManager {
|
object TransportManager {
|
||||||
private const val TAG = "TransportManager"
|
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val INITIAL_BACKOFF_MS = 1000L
|
private const val INITIAL_BACKOFF_MS = 1000L
|
||||||
|
|
||||||
// Fallback transport server (CDN)
|
|
||||||
private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com"
|
|
||||||
|
|
||||||
private var transportServer: String? = null
|
private var transportServer: String? = null
|
||||||
|
|
||||||
@@ -54,20 +49,32 @@ object TransportManager {
|
|||||||
* Установить адрес транспортного сервера
|
* Установить адрес транспортного сервера
|
||||||
*/
|
*/
|
||||||
fun setTransportServer(server: String) {
|
fun setTransportServer(server: String) {
|
||||||
transportServer = server
|
val normalized = server.trim().trimEnd('/')
|
||||||
|
transportServer = normalized.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить адрес транспортного сервера (с fallback)
|
* Получить текущий адрес транспортного сервера
|
||||||
*/
|
*/
|
||||||
fun getTransportServer(): String = transportServer ?: FALLBACK_TRANSPORT_SERVER
|
fun getTransportServer(): String? = transportServer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить активный сервер для скачивания/загрузки
|
* Получить активный сервер для скачивания/загрузки.
|
||||||
|
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
|
||||||
*/
|
*/
|
||||||
private fun getActiveServer(): String {
|
private suspend fun getActiveServer(): String {
|
||||||
val server = transportServer ?: FALLBACK_TRANSPORT_SERVER
|
transportServer?.let { return it }
|
||||||
return server
|
|
||||||
|
requestTransportServer()
|
||||||
|
repeat(40) { // 10s total
|
||||||
|
val server = transportServer
|
||||||
|
if (!server.isNullOrBlank()) {
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IOException("Transport server is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ import com.rosetta.messenger.MainActivity
|
|||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.PreferencesManager
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
|
import com.rosetta.messenger.network.PacketPushNotification
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
|
import com.rosetta.messenger.network.PushNotificationAction
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Firebase Cloud Messaging Service для обработки push-уведомлений
|
* Firebase Cloud Messaging Service для обработки push-уведомлений
|
||||||
@@ -47,7 +52,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
|
fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
|
||||||
val notificationManager =
|
val notificationManager =
|
||||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
notificationManager.cancel(getNotificationIdForChat(senderPublicKey))
|
val normalizedKey = senderPublicKey.trim()
|
||||||
|
if (normalizedKey.isNotEmpty()) {
|
||||||
|
notificationManager.cancel(getNotificationIdForChat(normalizedKey))
|
||||||
|
}
|
||||||
|
// Fallback: некоторые серверные payload могут прийти без sender key.
|
||||||
|
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
|
||||||
|
notificationManager.cancel(getNotificationIdForChat(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,36 +69,79 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
// Сохраняем токен локально
|
// Сохраняем токен локально
|
||||||
saveFcmToken(token)
|
saveFcmToken(token)
|
||||||
|
|
||||||
// 📤 Токен будет отправлен на сервер после успешного логина в MainActivity
|
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
|
||||||
|
if (ProtocolManager.isAuthenticated()) {
|
||||||
|
runCatching {
|
||||||
|
ProtocolManager.send(
|
||||||
|
PacketPushNotification().apply {
|
||||||
|
notificationsToken = token
|
||||||
|
action = PushNotificationAction.SUBSCRIBE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вызывается когда получено push-уведомление */
|
/** Вызывается когда получено push-уведомление */
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
super.onMessageReceived(remoteMessage)
|
super.onMessageReceived(remoteMessage)
|
||||||
|
|
||||||
// Обрабатываем data payload
|
var handledMessageData = false
|
||||||
remoteMessage.data.isNotEmpty().let {
|
|
||||||
val type = remoteMessage.data["type"]
|
|
||||||
val senderPublicKey = remoteMessage.data["sender_public_key"]
|
|
||||||
val senderName =
|
|
||||||
remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown"
|
|
||||||
val messagePreview = remoteMessage.data["message_preview"] ?: "New message"
|
|
||||||
|
|
||||||
when (type) {
|
// Обрабатываем data payload
|
||||||
"new_message" -> {
|
if (remoteMessage.data.isNotEmpty()) {
|
||||||
// Показываем уведомление о новом сообщении
|
val data = remoteMessage.data
|
||||||
|
val type =
|
||||||
|
firstNonBlank(data, "type", "event", "action")
|
||||||
|
?.lowercase(Locale.ROOT)
|
||||||
|
.orEmpty()
|
||||||
|
val senderPublicKey =
|
||||||
|
firstNonBlank(
|
||||||
|
data,
|
||||||
|
"sender_public_key",
|
||||||
|
"from_public_key",
|
||||||
|
"fromPublicKey",
|
||||||
|
"public_key",
|
||||||
|
"publicKey"
|
||||||
|
)
|
||||||
|
val senderName =
|
||||||
|
firstNonBlank(data, "sender_name", "from_title", "sender", "title", "name")
|
||||||
|
?: senderPublicKey?.take(10)
|
||||||
|
?: "Rosetta"
|
||||||
|
val messagePreview =
|
||||||
|
firstNonBlank(data, "message_preview", "message", "text", "body")
|
||||||
|
?: "New message"
|
||||||
|
|
||||||
|
val isReadEvent = type == "message_read" || type == "read"
|
||||||
|
val isMessageEvent =
|
||||||
|
type == "new_message" ||
|
||||||
|
type == "message" ||
|
||||||
|
type == "newmessage" ||
|
||||||
|
type == "msg_new"
|
||||||
|
|
||||||
|
when {
|
||||||
|
isMessageEvent -> {
|
||||||
showMessageNotification(senderPublicKey, senderName, messagePreview)
|
showMessageNotification(senderPublicKey, senderName, messagePreview)
|
||||||
|
handledMessageData = true
|
||||||
}
|
}
|
||||||
"message_read" -> {
|
isReadEvent -> {
|
||||||
// Сообщение прочитано - можно обновить UI если приложение открыто
|
handledMessageData = true
|
||||||
|
}
|
||||||
|
// Fallback for servers sending data-only payload without explicit "type".
|
||||||
|
senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> {
|
||||||
|
showMessageNotification(senderPublicKey, senderName, messagePreview)
|
||||||
|
handledMessageData = true
|
||||||
}
|
}
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обрабатываем notification payload (если есть)
|
// Обрабатываем notification payload (если есть).
|
||||||
|
// Для new_message используем data-ветку выше, чтобы не показывать дубликаты
|
||||||
|
// с неуправляемым notification id.
|
||||||
remoteMessage.notification?.let {
|
remoteMessage.notification?.let {
|
||||||
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
|
if (!handledMessageData) {
|
||||||
|
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +152,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
messagePreview: String
|
messagePreview: String
|
||||||
) {
|
) {
|
||||||
// 🔥 Не показываем уведомление если приложение открыто
|
// 🔥 Не показываем уведомление если приложение открыто
|
||||||
if (isAppInForeground) {
|
if (isAppInForeground || !areNotificationsEnabled()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val senderKey = senderPublicKey?.trim().orEmpty()
|
val senderKey = senderPublicKey?.trim().orEmpty()
|
||||||
@@ -144,7 +198,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
/** Показать простое уведомление */
|
/** Показать простое уведомление */
|
||||||
private fun showSimpleNotification(title: String, body: String) {
|
private fun showSimpleNotification(title: String, body: String) {
|
||||||
// 🔥 Не показываем уведомление если приложение открыто
|
// 🔥 Не показываем уведомление если приложение открыто
|
||||||
if (isAppInForeground) {
|
if (isAppInForeground || !areNotificationsEnabled()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +258,22 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
prefs.edit().putString("fcm_token", token).apply()
|
prefs.edit().putString("fcm_token", token).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun areNotificationsEnabled(): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
PreferencesManager(applicationContext).notificationsEnabled.first()
|
||||||
|
}
|
||||||
|
}.getOrDefault(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstNonBlank(data: Map<String, String>, vararg keys: String): String? {
|
||||||
|
for (key in keys) {
|
||||||
|
val value = data[key]?.trim()
|
||||||
|
if (!value.isNullOrEmpty()) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** Проверка: замьючен ли диалог для текущего аккаунта */
|
/** Проверка: замьючен ли диалог для текущего аккаунта */
|
||||||
private fun isDialogMuted(senderPublicKey: String): Boolean {
|
private fun isDialogMuted(senderPublicKey: String): Boolean {
|
||||||
if (senderPublicKey.isBlank()) return false
|
if (senderPublicKey.isBlank()) return false
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.rosetta.messenger.ui.auth
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
internal suspend fun awaitAuthHandshakeState(
|
||||||
|
publicKey: String,
|
||||||
|
privateKeyHash: String,
|
||||||
|
attempts: Int = 2,
|
||||||
|
timeoutMs: Long = 25_000L
|
||||||
|
): ProtocolState? {
|
||||||
|
repeat(attempts) {
|
||||||
|
ProtocolManager.disconnect()
|
||||||
|
delay(200)
|
||||||
|
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
||||||
|
|
||||||
|
val state = withTimeoutOrNull(timeoutMs) {
|
||||||
|
ProtocolManager.state.first {
|
||||||
|
it == ProtocolState.AUTHENTICATED ||
|
||||||
|
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state != null) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package com.rosetta.messenger.ui.auth
|
package com.rosetta.messenger.ui.auth
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -15,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -29,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -54,11 +62,16 @@ fun DeviceConfirmScreen(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onExit: () -> Unit
|
onExit: () -> Unit
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundTop = if (isDarkTheme) Color(0xFF17181D) else Color(0xFFF4F7FC)
|
||||||
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
val backgroundBottom = if (isDarkTheme) Color(0xFF121316) else Color(0xFFE9EEF7)
|
||||||
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
|
val cardColor = if (isDarkTheme) Color(0xFF23252B) else Color.White
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val cardBorderColor = if (isDarkTheme) Color(0xFF343844) else Color(0xFFDCE4F0)
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val textColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF1B1C1F)
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFFB2B5BD) else Color(0xFF6F7480)
|
||||||
|
val accentColor = if (isDarkTheme) Color(0xFF4A9FFF) else PrimaryBlue
|
||||||
|
val deviceCardColor = if (isDarkTheme) Color(0xFF1A1C22) else Color(0xFFF5F8FD)
|
||||||
|
val exitButtonColor = if (isDarkTheme) Color(0xFF3D2227) else Color(0xFFFFEAED)
|
||||||
|
val exitButtonTextColor = Color(0xFFFF5E61)
|
||||||
val onExitState by rememberUpdatedState(onExit)
|
val onExitState by rememberUpdatedState(onExit)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -92,7 +105,12 @@ fun DeviceConfirmScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(backgroundColor)
|
.background(
|
||||||
|
brush =
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(backgroundTop, backgroundBottom)
|
||||||
|
)
|
||||||
|
)
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(horizontal = 22.dp),
|
.padding(horizontal = 22.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -102,98 +120,160 @@ fun DeviceConfirmScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 400.dp),
|
.widthIn(max = 400.dp),
|
||||||
color = cardColor,
|
color = cardColor,
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
border = BorderStroke(1.dp, cardBorderColor)
|
border = BorderStroke(1.dp, cardBorderColor)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
modifier = Modifier.padding(horizontal = 22.dp, vertical = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
LottieAnimation(
|
Box(
|
||||||
composition = composition,
|
modifier =
|
||||||
progress = { progress },
|
Modifier
|
||||||
modifier = Modifier.size(128.dp)
|
.size(118.dp)
|
||||||
)
|
.clip(CircleShape)
|
||||||
|
.background(accentColor.copy(alpha = if (isDarkTheme) 0.16f else 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LottieAnimation(
|
||||||
|
composition = composition,
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.size(96.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = TablerIcons.DeviceMobile,
|
imageVector = TablerIcons.DeviceMobile,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = PrimaryBlue
|
tint = accentColor
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(6.dp))
|
Spacer(modifier = Modifier.size(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "NEW DEVICE REQUEST",
|
text = "NEW DEVICE REQUEST",
|
||||||
color = PrimaryBlue,
|
color = accentColor,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Waiting for approval",
|
|
||||||
color = textColor,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Open Rosetta on your first device and approve this login request.",
|
|
||||||
color = secondaryTextColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
lineHeight = 20.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "\"$localDeviceName\" is waiting for approval",
|
text = "Waiting for approval",
|
||||||
color = textColor.copy(alpha = 0.9f),
|
color = textColor,
|
||||||
fontSize = 13.sp,
|
fontSize = 34.sp,
|
||||||
textAlign = TextAlign.Center,
|
lineHeight = 38.sp,
|
||||||
lineHeight = 20.sp
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "If you didn't request this login, tap Exit.",
|
|
||||||
color = secondaryTextColor,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Button(
|
Text(
|
||||||
onClick = onExitState,
|
text = "Open Rosetta on your first device and approve this login request.",
|
||||||
modifier = Modifier.height(42.dp),
|
color = secondaryTextColor,
|
||||||
colors = ButtonDefaults.buttonColors(
|
fontSize = 15.sp,
|
||||||
containerColor = Color(0xFFFF3B30),
|
textAlign = TextAlign.Center,
|
||||||
contentColor = Color.White
|
lineHeight = 22.sp
|
||||||
),
|
)
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = deviceCardColor,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
border = BorderStroke(1.dp, cardBorderColor.copy(alpha = if (isDarkTheme) 0.7f else 1f))
|
||||||
) {
|
) {
|
||||||
Text("Exit")
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Device waiting for approval",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = localDeviceName,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Button(
|
||||||
text = "Waiting for confirmation...",
|
onClick = onExitState,
|
||||||
color = secondaryTextColor,
|
modifier =
|
||||||
fontSize = 11.sp
|
Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.height(46.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = exitButtonColor,
|
||||||
|
contentColor = exitButtonTextColor
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, exitButtonTextColor.copy(alpha = 0.35f)),
|
||||||
|
shape = RoundedCornerShape(14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Exit",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Waiting for confirmation",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
WaitingDots(color = secondaryTextColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitingDots(color: Color) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "waiting-dots")
|
||||||
|
val progress by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing)
|
||||||
|
),
|
||||||
|
label = "waiting-dots-progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
repeat(3) { index ->
|
||||||
|
val shifted = (progress + index * 0.22f) % 1f
|
||||||
|
val alpha = 0.3f + (1f - shifted) * 0.7f
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(5.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color.copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import com.rosetta.messenger.crypto.CryptoManager
|
|||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -520,20 +519,26 @@ fun SetPasswordScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
accountManager.saveAccount(account)
|
accountManager.saveAccount(account)
|
||||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
|
||||||
|
|
||||||
// 🔌 Connect to server and authenticate
|
// 🔌 Connect to server and authenticate
|
||||||
val privateKeyHash =
|
val privateKeyHash =
|
||||||
CryptoManager.generatePrivateKeyHash(
|
CryptoManager.generatePrivateKeyHash(
|
||||||
keyPair.privateKey
|
keyPair.privateKey
|
||||||
)
|
)
|
||||||
ProtocolManager.connect()
|
|
||||||
// Give WebSocket time to connect before authenticating
|
val handshakeState =
|
||||||
kotlinx.coroutines.delay(500)
|
awaitAuthHandshakeState(
|
||||||
ProtocolManager.authenticate(
|
keyPair.publicKey,
|
||||||
keyPair.publicKey,
|
privateKeyHash
|
||||||
privateKeyHash
|
)
|
||||||
)
|
if (handshakeState == null) {
|
||||||
|
error =
|
||||||
|
"Failed to connect to server. Please try again."
|
||||||
|
isCreating = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||||
|
|
||||||
// Create DecryptedAccount to pass to callback
|
// Create DecryptedAccount to pass to callback
|
||||||
val decryptedAccount =
|
val decryptedAccount =
|
||||||
|
|||||||
@@ -43,19 +43,16 @@ import com.rosetta.messenger.crypto.CryptoManager
|
|||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarText
|
import com.rosetta.messenger.ui.chats.getAvatarText
|
||||||
import com.rosetta.messenger.ui.chats.utils.getInitials
|
import com.rosetta.messenger.ui.chats.utils.getInitials
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// Account model for dropdown
|
// Account model for dropdown
|
||||||
data class AccountItem(
|
data class AccountItem(
|
||||||
@@ -116,33 +113,17 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
|||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
seedPhrase = decryptedSeedPhrase,
|
seedPhrase = decryptedSeedPhrase,
|
||||||
privateKeyHash = privateKeyHash,
|
privateKeyHash = privateKeyHash,
|
||||||
name = account.name
|
name = selectedAccount.name
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connect to server
|
val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
|
||||||
val connectStart = System.currentTimeMillis()
|
if (handshakeState == null) {
|
||||||
ProtocolManager.connect()
|
|
||||||
|
|
||||||
// Wait for websocket connection
|
|
||||||
val connected = withTimeoutOrNull(5000) {
|
|
||||||
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
|
|
||||||
}
|
|
||||||
val connectTime = System.currentTimeMillis() - connectStart
|
|
||||||
if (connected == null) {
|
|
||||||
onError("Failed to connect to server")
|
onError("Failed to connect to server")
|
||||||
onUnlocking(false)
|
onUnlocking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinx.coroutines.delay(300)
|
|
||||||
|
|
||||||
// Authenticate
|
|
||||||
val authStart = System.currentTimeMillis()
|
|
||||||
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
|
||||||
val authTime = System.currentTimeMillis() - authStart
|
|
||||||
accountManager.setCurrentAccount(account.publicKey)
|
accountManager.setCurrentAccount(account.publicKey)
|
||||||
|
|
||||||
val totalTime = System.currentTimeMillis() - totalStart
|
|
||||||
onSuccess(decryptedAccount)
|
onSuccess(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onError("Failed to unlock: ${e.message}")
|
onError("Failed to unlock: ${e.message}")
|
||||||
@@ -216,7 +197,11 @@ fun UnlockScreen(
|
|||||||
val allAccounts = accountManager.getAllAccounts()
|
val allAccounts = accountManager.getAllAccounts()
|
||||||
accounts =
|
accounts =
|
||||||
allAccounts.map { acc ->
|
allAccounts.map { acc ->
|
||||||
AccountItem(publicKey = acc.publicKey, name = acc.name, encryptedAccount = acc)
|
AccountItem(
|
||||||
|
publicKey = acc.publicKey,
|
||||||
|
name = resolveAccountDisplayName(acc.publicKey, acc.name, acc.username),
|
||||||
|
encryptedAccount = acc
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый
|
// Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый
|
||||||
@@ -359,6 +344,7 @@ fun UnlockScreen(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
size = 120.dp,
|
size = 120.dp,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
displayName = selectedAccount!!.name,
|
||||||
shape = RoundedCornerShape(28.dp)
|
shape = RoundedCornerShape(28.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -479,7 +465,8 @@ fun UnlockScreen(
|
|||||||
publicKey = account.publicKey,
|
publicKey = account.publicKey,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
size = 40.dp,
|
size = 40.dp,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
displayName = account.name
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ import com.rosetta.messenger.data.ForwardManager
|
|||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.components.*
|
import com.rosetta.messenger.ui.chats.components.*
|
||||||
@@ -339,14 +338,6 @@ fun ChatDetailScreen(
|
|||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||||
var showDebugLogs by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Debug logs из ProtocolManager
|
|
||||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
|
||||||
|
|
||||||
// Включаем UI логи только когда открыт bottom sheet
|
|
||||||
LaunchedEffect(showDebugLogs) { ProtocolManager.enableUILogs(showDebugLogs) }
|
|
||||||
|
|
||||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||||
val isBlocked by
|
val isBlocked by
|
||||||
database.blacklistDao()
|
database.blacklistDao()
|
||||||
@@ -462,6 +453,7 @@ fun ChatDetailScreen(
|
|||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
isScreenActive = true
|
isScreenActive = true
|
||||||
viewModel.setDialogActive(true)
|
viewModel.setDialogActive(true)
|
||||||
|
viewModel.markVisibleMessagesAsRead()
|
||||||
// 🔥 Убираем уведомление этого чата из шторки
|
// 🔥 Убираем уведомление этого чата из шторки
|
||||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||||
.cancelNotificationForChat(context, user.publicKey)
|
.cancelNotificationForChat(context, user.publicKey)
|
||||||
@@ -488,6 +480,7 @@ fun ChatDetailScreen(
|
|||||||
LaunchedEffect(user.publicKey, forwardTrigger) {
|
LaunchedEffect(user.publicKey, forwardTrigger) {
|
||||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||||
viewModel.openDialog(user.publicKey, user.title, user.username)
|
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||||
|
viewModel.markVisibleMessagesAsRead()
|
||||||
// 🔥 Убираем уведомление этого чата из шторки при заходе
|
// 🔥 Убираем уведомление этого чата из шторки при заходе
|
||||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||||
.cancelNotificationForChat(context, user.publicKey)
|
.cancelNotificationForChat(context, user.publicKey)
|
||||||
@@ -1143,12 +1136,6 @@ fun ChatDetailScreen(
|
|||||||
false
|
false
|
||||||
showDeleteConfirm =
|
showDeleteConfirm =
|
||||||
true
|
true
|
||||||
},
|
|
||||||
onLogsClick = {
|
|
||||||
showMenu =
|
|
||||||
false
|
|
||||||
showDebugLogs =
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1535,7 +1522,7 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!isSystemAccount) {
|
||||||
// INPUT BAR
|
// INPUT BAR
|
||||||
Column {
|
Column {
|
||||||
MessageInputBar(
|
MessageInputBar(
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ import com.rosetta.messenger.R
|
|||||||
import com.rosetta.messenger.BuildConfig
|
import com.rosetta.messenger.BuildConfig
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.network.DeviceEntry
|
import com.rosetta.messenger.network.DeviceEntry
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.chats.components.DebugLogsBottomSheet
|
|
||||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
@@ -261,7 +262,6 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Protocol connection state
|
// Protocol connection state
|
||||||
val protocolState by ProtocolManager.state.collectAsState()
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
val syncLogs by ProtocolManager.debugLogs.collectAsState()
|
|
||||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||||
|
|
||||||
// 🔥 Пользователи, которые сейчас печатают
|
// 🔥 Пользователи, которые сейчас печатают
|
||||||
@@ -288,10 +288,6 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Status dialog state
|
// Status dialog state
|
||||||
var showStatusDialog by remember { mutableStateOf(false) }
|
var showStatusDialog by remember { mutableStateOf(false) }
|
||||||
var showSyncLogs by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
|
|
||||||
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
|
|
||||||
|
|
||||||
// 📬 Requests screen state
|
// 📬 Requests screen state
|
||||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||||
@@ -667,12 +663,15 @@ fun ChatsListScreen(
|
|||||||
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
|
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
// All accounts list
|
// All accounts list (max 5 like Telegram sidebar behavior)
|
||||||
allAccounts.forEach { account ->
|
allAccounts.take(5).forEach { account ->
|
||||||
val isCurrentAccount = account.publicKey == accountPublicKey
|
val isCurrentAccount = account.publicKey == accountPublicKey
|
||||||
val displayName = account.name.ifEmpty {
|
val displayName =
|
||||||
account.username ?: account.publicKey.take(8)
|
resolveAccountDisplayName(
|
||||||
}
|
account.publicKey,
|
||||||
|
account.name,
|
||||||
|
account.username
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
@@ -1196,18 +1195,6 @@ fun ChatsListScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!showRequestsScreen) {
|
if (!showRequestsScreen) {
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
showSyncLogs = true
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
TablerIcons.Bug,
|
|
||||||
contentDescription = "Sync logs",
|
|
||||||
tint = Color.White.copy(alpha = 0.92f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (protocolState ==
|
if (protocolState ==
|
||||||
@@ -1561,8 +1548,6 @@ fun ChatsListScreen(
|
|||||||
DeviceVerificationBanner(
|
DeviceVerificationBanner(
|
||||||
device = pendingDevice,
|
device = pendingDevice,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
accountPublicKey = accountPublicKey,
|
|
||||||
avatarRepository = avatarRepository,
|
|
||||||
onAccept = {
|
onAccept = {
|
||||||
deviceResolveRequest =
|
deviceResolveRequest =
|
||||||
pendingDevice to
|
pendingDevice to
|
||||||
@@ -1627,6 +1612,9 @@ fun ChatsListScreen(
|
|||||||
val isSavedMessages =
|
val isSavedMessages =
|
||||||
dialog.opponentKey ==
|
dialog.opponentKey ==
|
||||||
accountPublicKey
|
accountPublicKey
|
||||||
|
val isSystemSafeDialog =
|
||||||
|
dialog.opponentKey ==
|
||||||
|
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||||
val isBlocked =
|
val isBlocked =
|
||||||
blockedUsers
|
blockedUsers
|
||||||
.contains(
|
.contains(
|
||||||
@@ -1734,6 +1722,8 @@ fun ChatsListScreen(
|
|||||||
.contains(
|
.contains(
|
||||||
dialog.opponentKey
|
dialog.opponentKey
|
||||||
),
|
),
|
||||||
|
swipeEnabled =
|
||||||
|
!isSystemSafeDialog,
|
||||||
onPin = {
|
onPin = {
|
||||||
onTogglePin(
|
onTogglePin(
|
||||||
dialog.opponentKey
|
dialog.opponentKey
|
||||||
@@ -1920,15 +1910,6 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSyncLogs) {
|
|
||||||
DebugLogsBottomSheet(
|
|
||||||
logs = syncLogs,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onDismiss = { showSyncLogs = false },
|
|
||||||
onClearLogs = { ProtocolManager.clearLogs() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} // Close Box
|
} // Close Box
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2482,6 +2463,7 @@ fun SwipeableDialogItem(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
|
swipeEnabled: Boolean = true,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
isDrawerOpen: Boolean = false,
|
isDrawerOpen: Boolean = false,
|
||||||
@@ -2513,7 +2495,10 @@ fun SwipeableDialogItem(
|
|||||||
)
|
)
|
||||||
var offsetX by remember { mutableStateOf(0f) }
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
|
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
|
||||||
val buttonCount = if (isSavedMessages) 2 else 3
|
val buttonCount =
|
||||||
|
if (!swipeEnabled) 0
|
||||||
|
else if (isSavedMessages) 2
|
||||||
|
else 3
|
||||||
val swipeWidthDp = (buttonCount * 80).dp
|
val swipeWidthDp = (buttonCount * 80).dp
|
||||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||||
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
|
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
|
||||||
@@ -2545,6 +2530,7 @@ fun SwipeableDialogItem(
|
|||||||
.clipToBounds()
|
.clipToBounds()
|
||||||
) {
|
) {
|
||||||
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
|
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
|
||||||
|
if (swipeEnabled) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.CenterEnd)
|
Modifier.align(Alignment.CenterEnd)
|
||||||
@@ -2665,6 +2651,7 @@ fun SwipeableDialogItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
|
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
|
||||||
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
|
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
|
||||||
@@ -2762,7 +2749,7 @@ fun SwipeableDialogItem(
|
|||||||
|
|
||||||
when {
|
when {
|
||||||
// Horizontal left swipe — reveal action buttons
|
// Horizontal left swipe — reveal action buttons
|
||||||
dominated && totalDragX < 0 -> {
|
swipeEnabled && dominated && totalDragX < 0 -> {
|
||||||
passedSlop = true
|
passedSlop = true
|
||||||
claimed = true
|
claimed = true
|
||||||
onSwipeStarted()
|
onSwipeStarted()
|
||||||
@@ -3073,12 +3060,31 @@ fun DialogItemContent(
|
|||||||
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
||||||
// галочки (прочитано)
|
// галочки (прочитано)
|
||||||
if (dialog.isSavedMessages) {
|
if (dialog.isSavedMessages) {
|
||||||
Icon(
|
Box(
|
||||||
painter = TelegramIcons.Done,
|
modifier = Modifier.width(20.dp).height(16.dp)
|
||||||
contentDescription = null,
|
) {
|
||||||
tint = PrimaryBlue,
|
Icon(
|
||||||
modifier = Modifier.size(16.dp)
|
painter = TelegramIcons.Done,
|
||||||
)
|
contentDescription = null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(16.dp)
|
||||||
|
.align(
|
||||||
|
Alignment.CenterStart
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(16.dp)
|
||||||
|
.align(
|
||||||
|
Alignment.CenterStart
|
||||||
|
)
|
||||||
|
.offset(x = 5.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
} else if (dialog.lastMessageFromMe == 1) {
|
} else if (dialog.lastMessageFromMe == 1) {
|
||||||
// Показываем статус только для исходящих сообщений
|
// Показываем статус только для исходящих сообщений
|
||||||
@@ -3116,14 +3122,44 @@ fun DialogItemContent(
|
|||||||
3 -> {
|
3 -> {
|
||||||
// READ (delivered=3) - две синие
|
// READ (delivered=3) - две синие
|
||||||
// галочки
|
// галочки
|
||||||
Icon(
|
Box(
|
||||||
painter =
|
|
||||||
TelegramIcons.Done,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryBlue,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(16.dp)
|
Modifier.width(20.dp)
|
||||||
)
|
.height(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter =
|
||||||
|
TelegramIcons.Done,
|
||||||
|
contentDescription =
|
||||||
|
null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(
|
||||||
|
16.dp
|
||||||
|
)
|
||||||
|
.align(
|
||||||
|
Alignment.CenterStart
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter =
|
||||||
|
TelegramIcons.Done,
|
||||||
|
contentDescription =
|
||||||
|
null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(
|
||||||
|
16.dp
|
||||||
|
)
|
||||||
|
.align(
|
||||||
|
Alignment.CenterStart
|
||||||
|
)
|
||||||
|
.offset(
|
||||||
|
x =
|
||||||
|
5.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.width(4.dp)
|
Modifier.width(4.dp)
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.airbnb.lottie.compose.*
|
import com.airbnb.lottie.compose.*
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
|
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
|
||||||
@@ -96,6 +98,8 @@ fun SearchScreen(
|
|||||||
val searchQuery by searchViewModel.searchQuery.collectAsState()
|
val searchQuery by searchViewModel.searchQuery.collectAsState()
|
||||||
val searchResults by searchViewModel.searchResults.collectAsState()
|
val searchResults by searchViewModel.searchResults.collectAsState()
|
||||||
val isSearching by searchViewModel.isSearching.collectAsState()
|
val isSearching by searchViewModel.isSearching.collectAsState()
|
||||||
|
var ownAccountName by remember(currentUserPublicKey) { mutableStateOf("") }
|
||||||
|
var ownAccountUsername by remember(currentUserPublicKey) { mutableStateOf("") }
|
||||||
|
|
||||||
// Easter egg: navigate to CrashLogs when typing "rosettadev1"
|
// Easter egg: navigate to CrashLogs when typing "rosettadev1"
|
||||||
LaunchedEffect(searchQuery) {
|
LaunchedEffect(searchQuery) {
|
||||||
@@ -108,6 +112,24 @@ fun SearchScreen(
|
|||||||
// Always reset query/results when leaving Search screen (back/swipe/navigation).
|
// Always reset query/results when leaving Search screen (back/swipe/navigation).
|
||||||
DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } }
|
DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } }
|
||||||
|
|
||||||
|
// Keep private key hash in sync with active account.
|
||||||
|
LaunchedEffect(privateKeyHash) {
|
||||||
|
searchViewModel.setPrivateKeyHash(privateKeyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep own account metadata for local "Saved Messages" search fallback.
|
||||||
|
LaunchedEffect(currentUserPublicKey) {
|
||||||
|
if (currentUserPublicKey.isBlank()) {
|
||||||
|
ownAccountName = ""
|
||||||
|
ownAccountUsername = ""
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = AccountManager(context).getAccount(currentUserPublicKey)
|
||||||
|
ownAccountName = account?.name?.trim().orEmpty()
|
||||||
|
ownAccountUsername = account?.username?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
// Recent users - отложенная подписка
|
// Recent users - отложенная подписка
|
||||||
val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
|
val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
|
||||||
|
|
||||||
@@ -150,11 +172,6 @@ fun SearchScreen(
|
|||||||
RecentSearchesManager.setAccount(currentUserPublicKey)
|
RecentSearchesManager.setAccount(currentUserPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем privateKeyHash
|
|
||||||
if (privateKeyHash.isNotEmpty()) {
|
|
||||||
searchViewModel.setPrivateKeyHash(privateKeyHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Автофокус с небольшой задержкой
|
// Автофокус с небольшой задержкой
|
||||||
kotlinx.coroutines.delay(100)
|
kotlinx.coroutines.delay(100)
|
||||||
try {
|
try {
|
||||||
@@ -314,15 +331,22 @@ fun SearchScreen(
|
|||||||
} else {
|
} else {
|
||||||
// Search Results
|
// Search Results
|
||||||
// Проверяем, не ищет ли пользователь сам себя (Saved Messages)
|
// Проверяем, не ищет ли пользователь сам себя (Saved Messages)
|
||||||
|
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
||||||
|
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
||||||
|
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
||||||
|
val normalizedName = ownAccountName.trim().lowercase()
|
||||||
|
val hasValidOwnName =
|
||||||
|
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
|
||||||
val isSavedMessagesSearch =
|
val isSavedMessagesSearch =
|
||||||
searchQuery.trim().let { query ->
|
normalizedQuery.isNotEmpty() &&
|
||||||
query.equals(currentUserPublicKey, ignoreCase = true) ||
|
(normalizedPublicKey == normalizedQuery ||
|
||||||
query.equals(currentUserPublicKey.take(8), ignoreCase = true) ||
|
normalizedPublicKey.startsWith(normalizedQuery) ||
|
||||||
query.equals(
|
normalizedPublicKey.take(8) == normalizedQuery ||
|
||||||
currentUserPublicKey.takeLast(8),
|
normalizedPublicKey.takeLast(8) == normalizedQuery ||
|
||||||
ignoreCase = true
|
(normalizedUsername.isNotEmpty() &&
|
||||||
)
|
normalizedUsername.startsWith(normalizedQuery)) ||
|
||||||
}
|
(hasValidOwnName &&
|
||||||
|
normalizedName.startsWith(normalizedQuery)))
|
||||||
|
|
||||||
// Если ищем себя - показываем Saved Messages как первый результат
|
// Если ищем себя - показываем Saved Messages как первый результат
|
||||||
val resultsWithSavedMessages =
|
val resultsWithSavedMessages =
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rosetta.messenger.network.PacketSearch
|
import com.rosetta.messenger.network.PacketSearch
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -13,8 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val TAG = "SearchUsersVM"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel для поиска пользователей через протокол
|
* ViewModel для поиска пользователей через протокол
|
||||||
* Работает аналогично SearchBar в React Native приложении
|
* Работает аналогично SearchBar в React Native приложении
|
||||||
@@ -33,38 +33,30 @@ class SearchUsersViewModel : ViewModel() {
|
|||||||
|
|
||||||
private val _isSearchExpanded = MutableStateFlow(false)
|
private val _isSearchExpanded = MutableStateFlow(false)
|
||||||
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow()
|
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchLogs = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val searchLogs: StateFlow<List<String>> = _searchLogs.asStateFlow()
|
||||||
|
|
||||||
// Приватные переменные
|
// Приватные переменные
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var lastSearchedText: String = ""
|
|
||||||
private var privateKeyHash: String = ""
|
private var privateKeyHash: String = ""
|
||||||
|
private val timeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
// Callback для обработки ответа поиска
|
// Callback для обработки ответа поиска
|
||||||
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
|
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
|
||||||
if (packet is PacketSearch) {
|
if (packet is PacketSearch) {
|
||||||
// 🔥 ВАЖНО: Игнорируем ответы с пустым search или не соответствующие нашему запросу
|
logSearch(
|
||||||
// Сервер может слать много пакетов 0x03 по разным причинам
|
"📥 PacketSearch response: search='${packet.search}', users=${packet.users.size}"
|
||||||
val currentQuery = lastSearchedText
|
)
|
||||||
val responseSearch = packet.search
|
// Desktop parity: любой ответ PacketSearch обновляет результаты
|
||||||
|
// пока в поле есть активный поисковый запрос.
|
||||||
|
if (_searchQuery.value.trim().isEmpty()) {
|
||||||
// Принимаем ответ только если:
|
logSearch("⏭ Ignored response: query is empty")
|
||||||
// 1. search в ответе совпадает с нашим запросом, ИЛИ
|
|
||||||
// 2. search пустой но мы ждём ответ (lastSearchedText не пустой)
|
|
||||||
// НО: если search пустой и мы НЕ ждём ответ - игнорируем
|
|
||||||
if (responseSearch.isEmpty() && currentQuery.isEmpty()) {
|
|
||||||
return@handler
|
return@handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если search не пустой и не совпадает с нашим запросом - игнорируем
|
|
||||||
if (responseSearch.isNotEmpty() && responseSearch != currentQuery) {
|
|
||||||
return@handler
|
|
||||||
}
|
|
||||||
|
|
||||||
packet.users.forEachIndexed { index, user ->
|
|
||||||
}
|
|
||||||
_searchResults.value = packet.users
|
_searchResults.value = packet.users
|
||||||
_isSearching.value = false
|
_isSearching.value = false
|
||||||
|
logSearch("✅ Results updated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +76,14 @@ class SearchUsersViewModel : ViewModel() {
|
|||||||
* Установить приватный ключ для поиска
|
* Установить приватный ключ для поиска
|
||||||
*/
|
*/
|
||||||
fun setPrivateKeyHash(hash: String) {
|
fun setPrivateKeyHash(hash: String) {
|
||||||
privateKeyHash = hash
|
privateKeyHash = hash.trim()
|
||||||
|
val shortHash =
|
||||||
|
if (privateKeyHash.length > 12) {
|
||||||
|
"${privateKeyHash.take(8)}...${privateKeyHash.takeLast(4)}"
|
||||||
|
} else {
|
||||||
|
privateKeyHash
|
||||||
|
}
|
||||||
|
logSearch("🔑 privateKeyHash set: $shortHash")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,53 +91,51 @@ class SearchUsersViewModel : ViewModel() {
|
|||||||
* Аналогично handleSearch в React Native
|
* Аналогично handleSearch в React Native
|
||||||
*/
|
*/
|
||||||
fun onSearchQueryChange(query: String) {
|
fun onSearchQueryChange(query: String) {
|
||||||
_searchQuery.value = query
|
val normalizedQuery = sanitizeSearchInput(query)
|
||||||
|
_searchQuery.value = normalizedQuery
|
||||||
|
logSearch("⌨️ Query changed: '$query' -> '$normalizedQuery'")
|
||||||
|
|
||||||
// Отменяем предыдущий поиск
|
// Отменяем предыдущий поиск
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
|
||||||
// Если пустой запрос - очищаем результаты
|
// Если пустой запрос - очищаем результаты
|
||||||
if (query.trim().isEmpty()) {
|
if (normalizedQuery.trim().isEmpty()) {
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
_isSearching.value = false
|
_isSearching.value = false
|
||||||
lastSearchedText = ""
|
logSearch("🧹 Cleared results: empty query")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если текст уже был найден - не повторяем поиск
|
|
||||||
if (query == lastSearchedText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показываем индикатор загрузки
|
// Показываем индикатор загрузки
|
||||||
_isSearching.value = true
|
_isSearching.value = true
|
||||||
|
logSearch("⏳ Debounce started (1000ms)")
|
||||||
|
|
||||||
// Запускаем поиск с задержкой 1 секунда (как в React Native)
|
// Запускаем поиск с задержкой 1 секунда (как в React Native)
|
||||||
searchJob = viewModelScope.launch {
|
searchJob = viewModelScope.launch {
|
||||||
delay(1000) // debounce
|
delay(1000) // debounce
|
||||||
|
|
||||||
|
|
||||||
// Проверяем состояние протокола
|
|
||||||
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
|
|
||||||
_isSearching.value = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, не изменился ли запрос
|
// Проверяем, не изменился ли запрос
|
||||||
if (query != _searchQuery.value) {
|
if (normalizedQuery != _searchQuery.value) {
|
||||||
|
logSearch("⏭ Skip send: query changed during debounce")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectivePrivateHash =
|
||||||
|
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() }
|
||||||
|
if (effectivePrivateHash.isBlank()) {
|
||||||
|
_isSearching.value = false
|
||||||
|
logSearch("❌ Skip send: private hash is empty")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSearchedText = query
|
|
||||||
|
|
||||||
|
|
||||||
// Создаем и отправляем пакет поиска
|
// Создаем и отправляем пакет поиска
|
||||||
val packetSearch = PacketSearch().apply {
|
val packetSearch = PacketSearch().apply {
|
||||||
this.privateKey = privateKeyHash
|
this.privateKey = effectivePrivateHash
|
||||||
this.search = query
|
this.search = normalizedQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.sendPacket(packetSearch)
|
ProtocolManager.sendPacket(packetSearch)
|
||||||
|
logSearch("📤 PacketSearch sent: '$normalizedQuery'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +154,8 @@ class SearchUsersViewModel : ViewModel() {
|
|||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
_isSearching.value = false
|
_isSearching.value = false
|
||||||
lastSearchedText = ""
|
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
logSearch("↩️ Search collapsed")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +165,18 @@ class SearchUsersViewModel : ViewModel() {
|
|||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
_searchResults.value = emptyList()
|
_searchResults.value = emptyList()
|
||||||
_isSearching.value = false
|
_isSearching.value = false
|
||||||
lastSearchedText = ""
|
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
logSearch("🧹 Query cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearchLogs() {
|
||||||
|
_searchLogs.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logSearch(message: String) {
|
||||||
|
val timestamp = synchronized(timeFormatter) { timeFormatter.format(Date()) }
|
||||||
|
_searchLogs.value = (_searchLogs.value + "[$timestamp] $message").takeLast(200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sanitizeSearchInput(input: String): String = input.replace("@", "").trimStart()
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ import kotlin.math.min
|
|||||||
|
|
||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
|
|
||||||
|
private fun shortDebugId(value: String): String {
|
||||||
|
if (value.isBlank()) return "empty"
|
||||||
|
val clean = value.trim()
|
||||||
|
return if (clean.length <= 8) clean else "${clean.take(8)}..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logPhotoDebug(message: String) {
|
||||||
|
AttachmentDownloadDebugLogger.log(message)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Анимированный текст с волнообразными точками.
|
* Анимированный текст с волнообразными точками.
|
||||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||||
@@ -910,6 +920,10 @@ fun ImageAttachment(
|
|||||||
val download: () -> Unit = {
|
val download: () -> Unit = {
|
||||||
if (downloadTag.isNotEmpty()) {
|
if (downloadTag.isNotEmpty()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val idShort = shortDebugId(attachment.id)
|
||||||
|
val tagShort = shortDebugId(downloadTag)
|
||||||
|
val server = TransportManager.getTransportServer() ?: "unset"
|
||||||
|
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
|
||||||
try {
|
try {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
|
||||||
@@ -917,6 +931,9 @@ fun ImageAttachment(
|
|||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||||
val downloadTime = System.currentTimeMillis() - startTime
|
val downloadTime = System.currentTimeMillis() - startTime
|
||||||
|
logPhotoDebug(
|
||||||
|
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
|
||||||
|
)
|
||||||
downloadProgress = 0.5f
|
downloadProgress = 0.5f
|
||||||
|
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
downloadStatus = DownloadStatus.DECRYPTING
|
||||||
@@ -925,6 +942,9 @@ fun ImageAttachment(
|
|||||||
// Сначала расшифровываем его, получаем raw bytes
|
// Сначала расшифровываем его, получаем raw bytes
|
||||||
val decryptedKeyAndNonce =
|
val decryptedKeyAndNonce =
|
||||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
logPhotoDebug(
|
||||||
|
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||||
|
)
|
||||||
|
|
||||||
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
||||||
// bytes в password
|
// bytes в password
|
||||||
@@ -938,6 +958,7 @@ fun ImageAttachment(
|
|||||||
downloadProgress = 0.8f
|
downloadProgress = 0.8f
|
||||||
|
|
||||||
if (decrypted != null) {
|
if (decrypted != null) {
|
||||||
|
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
imageBitmap = base64ToBitmap(decrypted)
|
imageBitmap = base64ToBitmap(decrypted)
|
||||||
|
|
||||||
@@ -950,18 +971,25 @@ fun ImageAttachment(
|
|||||||
publicKey = senderPublicKey,
|
publicKey = senderPublicKey,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
|
logPhotoDebug("Cache save result: id=$idShort, saved=$saved")
|
||||||
}
|
}
|
||||||
downloadProgress = 1f
|
downloadProgress = 1f
|
||||||
downloadStatus = DownloadStatus.DOWNLOADED
|
downloadStatus = DownloadStatus.DOWNLOADED
|
||||||
|
logPhotoDebug("Image ready: id=$idShort")
|
||||||
} else {
|
} else {
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
logPhotoDebug(
|
||||||
|
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logPhotoDebug("Skip image download: empty tag for id=${shortDebugId(attachment.id)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2167,11 +2195,21 @@ internal suspend fun downloadAndDecryptImage(
|
|||||||
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
|
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
val idShort = shortDebugId(attachmentId)
|
||||||
|
val tagShort = shortDebugId(downloadTag)
|
||||||
|
val server = TransportManager.getTransportServer() ?: "unset"
|
||||||
try {
|
try {
|
||||||
|
logPhotoDebug("Start helper image download: id=$idShort, tag=$tagShort, server=$server")
|
||||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||||
if (encryptedContent.isEmpty()) return@withContext null
|
if (encryptedContent.isEmpty()) return@withContext null
|
||||||
|
logPhotoDebug(
|
||||||
|
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||||
|
)
|
||||||
|
|
||||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
logPhotoDebug(
|
||||||
|
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
|
||||||
|
)
|
||||||
|
|
||||||
// Try decryptReplyBlob first (desktop decodeWithPassword)
|
// Try decryptReplyBlob first (desktop decodeWithPassword)
|
||||||
var decrypted = try {
|
var decrypted = try {
|
||||||
@@ -2192,16 +2230,22 @@ internal suspend fun downloadAndDecryptImage(
|
|||||||
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
|
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
|
||||||
|
|
||||||
ImageBitmapCache.put(cacheKey, bitmap)
|
ImageBitmapCache.put(cacheKey, bitmap)
|
||||||
AttachmentFileManager.saveAttachment(
|
val saved = AttachmentFileManager.saveAttachment(
|
||||||
context = context,
|
context = context,
|
||||||
blob = base64Data,
|
blob = base64Data,
|
||||||
attachmentId = attachmentId,
|
attachmentId = attachmentId,
|
||||||
publicKey = senderPublicKey,
|
publicKey = senderPublicKey,
|
||||||
privateKey = recipientPrivateKey
|
privateKey = recipientPrivateKey
|
||||||
)
|
)
|
||||||
|
logPhotoDebug("Helper image ready: id=$idShort, saved=$saved")
|
||||||
|
|
||||||
bitmap
|
bitmap
|
||||||
} catch (_: Exception) { null }
|
} catch (e: Exception) {
|
||||||
|
logPhotoDebug(
|
||||||
|
"Helper image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object AttachmentDownloadDebugLogger {
|
||||||
|
private const val MAX_LOGS = 200
|
||||||
|
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||||
|
|
||||||
|
fun log(message: String) {
|
||||||
|
val timestamp = dateFormat.format(Date())
|
||||||
|
val line = "[$timestamp] 🖼️ $message"
|
||||||
|
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_logs.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1148,19 +1148,48 @@ fun AnimatedMessageStatus(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
if (currentStatus == MessageStatus.READ) {
|
||||||
painter =
|
Box(
|
||||||
when (currentStatus) {
|
modifier =
|
||||||
MessageStatus.SENDING -> TelegramIcons.Clock
|
Modifier.width(iconSize + 6.dp)
|
||||||
MessageStatus.SENT -> TelegramIcons.Done
|
.height(iconSize)
|
||||||
MessageStatus.DELIVERED -> TelegramIcons.Done
|
.scale(scale)
|
||||||
MessageStatus.READ -> TelegramIcons.Done
|
) {
|
||||||
else -> TelegramIcons.Clock
|
Icon(
|
||||||
},
|
painter = TelegramIcons.Done,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = animatedColor,
|
tint = animatedColor,
|
||||||
modifier = Modifier.size(iconSize).scale(scale)
|
modifier =
|
||||||
)
|
Modifier.size(iconSize)
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = animatedColor,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(iconSize)
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter =
|
||||||
|
when (currentStatus) {
|
||||||
|
MessageStatus.SENDING ->
|
||||||
|
TelegramIcons.Clock
|
||||||
|
MessageStatus.SENT ->
|
||||||
|
TelegramIcons.Done
|
||||||
|
MessageStatus.DELIVERED ->
|
||||||
|
TelegramIcons.Done
|
||||||
|
else -> TelegramIcons.Clock
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = animatedColor,
|
||||||
|
modifier = Modifier.size(iconSize).scale(scale)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1833,8 +1862,7 @@ fun KebabMenu(
|
|||||||
isBlocked: Boolean,
|
isBlocked: Boolean,
|
||||||
onBlockClick: () -> Unit,
|
onBlockClick: () -> Unit,
|
||||||
onUnblockClick: () -> Unit,
|
onUnblockClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit
|
||||||
onLogsClick: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
|
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
|
||||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)
|
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)
|
||||||
|
|||||||
@@ -1,46 +1,44 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rosetta.messenger.network.DeviceEntry
|
import com.rosetta.messenger.network.DeviceEntry
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceVerificationBanner(
|
fun DeviceVerificationBanner(
|
||||||
device: DeviceEntry,
|
device: DeviceEntry,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
accountPublicKey: String,
|
|
||||||
avatarRepository: AvatarRepository?,
|
|
||||||
onAccept: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
onDecline: () -> Unit
|
onDecline: () -> Unit
|
||||||
) {
|
) {
|
||||||
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
val titleColor = if (isDarkTheme) Color.White else Color.Black
|
val titleColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF202124)
|
||||||
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val subtitleColor = if (isDarkTheme) Color(0xFFB7BAC1) else Color(0xFF6E7781)
|
||||||
val acceptColor = PrimaryBlue
|
val acceptColor = PrimaryBlue
|
||||||
val declineColor = Color(0xFFFF3B30)
|
val declineColor = Color(0xFFFF3B30)
|
||||||
|
|
||||||
val loginText =
|
val loginText =
|
||||||
buildString {
|
buildString {
|
||||||
append("New login from ")
|
append("We detected a new login to your account from ")
|
||||||
append(device.deviceName)
|
append(device.deviceName)
|
||||||
if (device.deviceOs.isNotBlank()) {
|
if (device.deviceOs.isNotBlank()) {
|
||||||
append(" (")
|
append(" (")
|
||||||
@@ -56,68 +54,62 @@ fun DeviceVerificationBanner(
|
|||||||
.background(itemBackground)
|
.background(itemBackground)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Row {
|
Text(
|
||||||
AvatarImage(
|
text = "Someone just got access to your messages!",
|
||||||
publicKey = accountPublicKey,
|
color = titleColor,
|
||||||
avatarRepository = avatarRepository,
|
fontSize = 15.sp,
|
||||||
size = 56.dp,
|
fontWeight = FontWeight.SemiBold,
|
||||||
isDarkTheme = isDarkTheme
|
maxLines = 2,
|
||||||
)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
|
text = loginText,
|
||||||
|
color = subtitleColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onAccept,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.height(30.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Someone just got access to your messages!",
|
text = "Yes, it's me",
|
||||||
color = titleColor,
|
color = acceptColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(3.dp))
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onDecline,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.height(30.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = loginText,
|
text = "No, it's not me!",
|
||||||
color = subtitleColor,
|
color = declineColor,
|
||||||
fontSize = 13.sp,
|
fontSize = 14.sp,
|
||||||
lineHeight = 17.sp,
|
fontWeight = FontWeight.SemiBold
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
TextButton(
|
|
||||||
onClick = onAccept,
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
modifier = Modifier.height(32.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Yes, it's me",
|
|
||||||
color = acceptColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = onDecline,
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
modifier = Modifier.height(32.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No, it's not me!",
|
|
||||||
color = declineColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -921,8 +921,22 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
val downloadTag = getDownloadTag(image.preview)
|
val downloadTag = getDownloadTag(image.preview)
|
||||||
if (downloadTag.isEmpty()) return null
|
if (downloadTag.isEmpty()) return null
|
||||||
|
|
||||||
|
val idShort =
|
||||||
|
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
||||||
|
val tagShort = if (downloadTag.length <= 8) downloadTag else "${downloadTag.take(8)}..."
|
||||||
|
val server = TransportManager.getTransportServer() ?: "unset"
|
||||||
|
AttachmentDownloadDebugLogger.log(
|
||||||
|
"Viewer download start: id=$idShort, tag=$tagShort, server=$server"
|
||||||
|
)
|
||||||
|
|
||||||
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
||||||
|
AttachmentDownloadDebugLogger.log(
|
||||||
|
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||||
|
)
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||||
|
AttachmentDownloadDebugLogger.log(
|
||||||
|
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||||
|
)
|
||||||
val decrypted =
|
val decrypted =
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
||||||
?: return null
|
?: return null
|
||||||
@@ -937,9 +951,15 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
publicKey = image.senderPublicKey,
|
publicKey = image.senderPublicKey,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
|
AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort")
|
||||||
|
|
||||||
decodedBitmap
|
decodedBitmap
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
|
val idShort =
|
||||||
|
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
||||||
|
AttachmentDownloadDebugLogger.log(
|
||||||
|
"Viewer image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
|
)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
|
|||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
@@ -545,48 +544,7 @@ fun OtherProfileScreen(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📋 INFORMATION SECTION — первый элемент
|
// ✉️ MESSAGE + 📞 CALL — первые элементы
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
TelegramSectionTitle(
|
|
||||||
title = "Information",
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
color = PrimaryBlue
|
|
||||||
)
|
|
||||||
|
|
||||||
if (user.username.isNotBlank()) {
|
|
||||||
TelegramCopyField(
|
|
||||||
value = "@${user.username}",
|
|
||||||
fullValue = user.username,
|
|
||||||
label = "Username",
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
|
|
||||||
Divider(
|
|
||||||
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
|
|
||||||
thickness = 0.5.dp,
|
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TelegramCopyField(
|
|
||||||
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
|
|
||||||
fullValue = user.publicKey,
|
|
||||||
label = "Public Key",
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// Разделитель секций
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
|
|
||||||
)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// ✉️ WRITE MESSAGE + 📞 CALL BUTTONS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
@@ -661,6 +619,47 @@ fun OtherProfileScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Разделитель секций
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 📋 INFORMATION SECTION — после кнопок
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
TelegramSectionTitle(
|
||||||
|
title = "Information",
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
color = PrimaryBlue
|
||||||
|
)
|
||||||
|
|
||||||
|
if (user.username.isNotBlank()) {
|
||||||
|
TelegramCopyField(
|
||||||
|
value = "@${user.username}",
|
||||||
|
fullValue = user.username,
|
||||||
|
label = "Username",
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TelegramCopyField(
|
||||||
|
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
|
||||||
|
fullValue = user.publicKey,
|
||||||
|
label = "Public Key",
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🔔 NOTIFICATIONS SECTION
|
// 🔔 NOTIFICATIONS SECTION
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -1825,7 +1824,6 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 👤 AVATAR — Telegram-style expansion on pull-down
|
// 👤 AVATAR — Telegram-style expansion on pull-down
|
||||||
// При скролле вверх: metaball merge с Dynamic Island
|
|
||||||
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
|
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val avatarSize = androidx.compose.ui.unit.lerp(
|
val avatarSize = androidx.compose.ui.unit.lerp(
|
||||||
@@ -1851,80 +1849,37 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
|
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
|
||||||
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
|
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
|
||||||
|
|
||||||
// Metaball alpha: visible only when NOT expanding (normal collapse animation)
|
Box(
|
||||||
val metaballAlpha = (1f - expandFraction * 10f).coerceIn(0f, 1f)
|
modifier = Modifier
|
||||||
// Expansion avatar alpha: visible when expanding
|
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
||||||
val expansionAvatarAlpha = (expandFraction * 10f).coerceIn(0f, 1f)
|
.graphicsLayer {
|
||||||
|
translationX = expandedAvatarXPx
|
||||||
// Layer 1: Metaball effect for normal collapse (fades out when expanding)
|
translationY = expandedAvatarYPx
|
||||||
if (metaballAlpha > 0.01f) {
|
alpha = avatarAlpha
|
||||||
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = metaballAlpha }) {
|
shape = RoundedCornerShape(cornerRadius)
|
||||||
ProfileMetaballEffect(
|
clip = true
|
||||||
collapseProgress = collapseProgress,
|
|
||||||
expansionProgress = 0f,
|
|
||||||
statusBarHeight = statusBarHeight,
|
|
||||||
headerHeight = headerHeight,
|
|
||||||
hasAvatar = hasAvatar,
|
|
||||||
avatarColor = avatarColors.backgroundColor,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
if (hasAvatar && avatarRepository != null) {
|
|
||||||
OtherProfileFullSizeAvatar(
|
|
||||||
publicKey = publicKey,
|
|
||||||
avatarRepository = avatarRepository,
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = getInitials(name),
|
|
||||||
fontSize = avatarFontSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = avatarColors.textColor
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
.background(avatarColors.backgroundColor),
|
||||||
}
|
contentAlignment = Alignment.Center
|
||||||
}
|
) {
|
||||||
}
|
if (hasAvatar && avatarRepository != null) {
|
||||||
|
OtherProfileFullSizeAvatar(
|
||||||
// Layer 2: Expanding avatar (fades in when pulling down)
|
publicKey = publicKey,
|
||||||
if (expansionAvatarAlpha > 0.01f) {
|
avatarRepository = avatarRepository,
|
||||||
Box(
|
isDarkTheme = isDarkTheme
|
||||||
modifier = Modifier
|
)
|
||||||
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
} else {
|
||||||
.graphicsLayer {
|
Text(
|
||||||
translationX = expandedAvatarXPx
|
text = getInitials(name),
|
||||||
translationY = expandedAvatarYPx
|
fontSize = avatarFontSize,
|
||||||
alpha = avatarAlpha * expansionAvatarAlpha
|
fontWeight = FontWeight.Bold,
|
||||||
shape = RoundedCornerShape(cornerRadius)
|
color = avatarColors.textColor
|
||||||
clip = true
|
)
|
||||||
}
|
|
||||||
.background(avatarColors.backgroundColor),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (hasAvatar && avatarRepository != null) {
|
|
||||||
OtherProfileFullSizeAvatar(
|
|
||||||
publicKey = publicKey,
|
|
||||||
avatarRepository = avatarRepository,
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = getInitials(name),
|
|
||||||
fontSize = avatarFontSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = avatarColors.textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gradient overlays when avatar is expanded
|
// Gradient overlays when avatar is expanded
|
||||||
if (expansionAvatarAlpha > 0.01f) {
|
if (expandFraction > 0.01f) {
|
||||||
// Top gradient
|
// Top gradient
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -1795,7 +1795,7 @@ fun TelegramToggleItem(
|
|||||||
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 iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
val accentColor = if (isDarkTheme) PrimaryBlueDark else Color(0xFF0D8CF4)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
29
app/src/main/res/raw/letsencrypt_r12.pem
Normal file
29
app/src/main/res/raw/letsencrypt_r12.pem
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFBjCCAu6gAwIBAgIRAMISMktwqbSRcdxA9+KFJjwwDQYJKoZIhvcNAQELBQAw
|
||||||
|
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||||
|
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
|
||||||
|
WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||||
|
RW5jcnlwdDEMMAoGA1UEAxMDUjEyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||||
|
CgKCAQEA2pgodK2+lP474B7i5Ut1qywSf+2nAzJ+Npfs6DGPpRONC5kuHs0BUT1M
|
||||||
|
5ShuCVUxqqUiXXL0LQfCTUA83wEjuXg39RplMjTmhnGdBO+ECFu9AhqZ66YBAJpz
|
||||||
|
kG2Pogeg0JfT2kVhgTU9FPnEwF9q3AuWGrCf4yrqvSrWmMebcas7dA8827JgvlpL
|
||||||
|
Thjp2ypzXIlhZZ7+7Tymy05v5J75AEaz/xlNKmOzjmbGGIVwx1Blbzt05UiDDwhY
|
||||||
|
XS0jnV6j/ujbAKHS9OMZTfLuevYnnuXNnC2i8n+cF63vEzc50bTILEHWhsDp7CH4
|
||||||
|
WRt/uTp8n1wBnWIEwii9Cq08yhDsGwIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB
|
||||||
|
hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB
|
||||||
|
/wIBADAdBgNVHQ4EFgQUALUp8i2ObzHom0yteD763OkM0dIwHwYDVR0jBBgwFoAU
|
||||||
|
ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC
|
||||||
|
hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG
|
||||||
|
A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN
|
||||||
|
AQELBQADggIBAI910AnPanZIZTKS3rVEyIV29BWEjAK/duuz8eL5boSoVpHhkkv3
|
||||||
|
4eoAeEiPdZLj5EZ7G2ArIK+gzhTlRQ1q4FKGpPPaFBSpqV/xbUb5UlAXQOnkHn3m
|
||||||
|
FVj+qYv87/WeY+Bm4sN3Ox8BhyaU7UAQ3LeZ7N1X01xxQe4wIAAE3JVLUCiHmZL+
|
||||||
|
qoCUtgYIFPgcg350QMUIWgxPXNGEncT921ne7nluI02V8pLUmClqXOsCwULw+PVO
|
||||||
|
ZCB7qOMxxMBoCUeL2Ll4oMpOSr5pJCpLN3tRA2s6P1KLs9TSrVhOk+7LX28NMUlI
|
||||||
|
usQ/nxLJID0RhAeFtPjyOCOscQBA53+NRjSCak7P4A5jX7ppmkcJECL+S0i3kXVU
|
||||||
|
y5Me5BbrU8973jZNv/ax6+ZK6TM8jWmimL6of6OrX7ZU6E2WqazzsFrLG3o2kySb
|
||||||
|
zlhSgJ81Cl4tv3SbYiYXnJExKQvzf83DYotox3f0fwv7xln1A2ZLplCb0O+l/AK0
|
||||||
|
YE0DS2FPxSAHi0iwMfW2nNHJrXcY3LLHD77gRgje4Eveubi2xxa+Nmk/hmhLdIET
|
||||||
|
iVDFanoCrMVIpQ59XWHkzdFmoHXHBV7oibVjGSO7ULSQ7MJ1Nz51phuDJSgAIU7A
|
||||||
|
0zrLnOrAj/dfrlEWRhCvAgbuwLZX1A2sjNjXoPOHbsPiy+lO1KF8/XY7
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
|
<!-- Базовые trust anchors для всех HTTPS доменов (включая новый CDN) -->
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<!-- GlobalSign GCC R6 AlphaSSL CA 2025 (цепочка *.rosetta.im) -->
|
||||||
|
<certificates src="@raw/globalsign_ca"/>
|
||||||
|
<!-- Let's Encrypt R12 (цепочка rosetta-im.com/defaultcontent) -->
|
||||||
|
<certificates src="@raw/letsencrypt_r12"/>
|
||||||
|
<certificates src="system"/>
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
|
||||||
<!-- HTTP для локального сервера -->
|
<!-- HTTP для локального сервера -->
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">46.28.71.12</domain>
|
<domain includeSubdomains="true">46.28.71.12</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
|
|
||||||
<!-- HTTPS с кастомным CA для CDN -->
|
|
||||||
<domain-config>
|
|
||||||
<domain includeSubdomains="true">cdn.rosetta-im.com</domain>
|
|
||||||
<domain includeSubdomains="true">rosetta-im.com</domain>
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="@raw/globalsign_ca"/>
|
|
||||||
<certificates src="system"/>
|
|
||||||
</trust-anchors>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
Reference in New Issue
Block a user