Релиз 1.5.1: merge dev в master и обновление ReleaseNotes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Some checks failed
Android Kernel Build / build (push) Has been cancelled
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.5.0"
|
val rosettaVersionName = "1.5.1"
|
||||||
val rosettaVersionCode = 52 // Increment on each release
|
val rosettaVersionCode = 53 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -47,10 +47,7 @@
|
|||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:screenOrientation="portrait">
|
android:screenOrientation="portrait">
|
||||||
<intent-filter>
|
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -65,6 +62,63 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- App Icon Aliases: only one enabled at a time -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityDefault"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityCalculator"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_calc"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_calc"
|
||||||
|
android:label="Calculator">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityWeather"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_weather"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_weather"
|
||||||
|
android:label="Weather">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityNotes"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_notes"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_notes"
|
||||||
|
android:label="Notes">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".IncomingCallActivity"
|
android:name=".IncomingCallActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -56,8 +56,15 @@ import com.rosetta.messenger.repository.AvatarRepository
|
|||||||
import com.rosetta.messenger.ui.auth.AccountInfo
|
import com.rosetta.messenger.ui.auth.AccountInfo
|
||||||
import com.rosetta.messenger.ui.auth.AuthFlow
|
import com.rosetta.messenger.ui.auth.AuthFlow
|
||||||
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
||||||
|
import com.rosetta.messenger.ui.auth.startAuthHandshakeFast
|
||||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||||
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
||||||
|
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
|
||||||
|
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
|
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
|
||||||
import com.rosetta.messenger.ui.chats.GroupInfoScreen
|
import com.rosetta.messenger.ui.chats.GroupInfoScreen
|
||||||
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
||||||
@@ -85,6 +92,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : FragmentActivity() {
|
class MainActivity : FragmentActivity() {
|
||||||
@@ -296,16 +304,57 @@ class MainActivity : FragmentActivity() {
|
|||||||
startInCreateMode = startCreateAccountFlow,
|
startInCreateMode = startCreateAccountFlow,
|
||||||
onAuthComplete = { account ->
|
onAuthComplete = { account ->
|
||||||
startCreateAccountFlow = false
|
startCreateAccountFlow = false
|
||||||
currentAccount = account
|
val normalizedAccount =
|
||||||
cacheSessionAccount(account)
|
account?.let {
|
||||||
|
val normalizedName =
|
||||||
|
resolveAccountDisplayName(
|
||||||
|
it.publicKey,
|
||||||
|
it.name,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (it.name == normalizedName) it
|
||||||
|
else it.copy(name = normalizedName)
|
||||||
|
}
|
||||||
|
currentAccount = normalizedAccount
|
||||||
|
cacheSessionAccount(normalizedAccount)
|
||||||
hasExistingAccount = true
|
hasExistingAccount = true
|
||||||
// Save as last logged account
|
// Save as last logged account
|
||||||
account?.let {
|
normalizedAccount?.let {
|
||||||
accountManager.setLastLoggedPublicKey(it.publicKey)
|
accountManager.setLastLoggedPublicKey(it.publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Первый запуск после регистрации:
|
||||||
|
// дополнительно перезапускаем auth/connect, чтобы не оставаться
|
||||||
|
// в "залипшем CONNECTING" до ручного рестарта приложения.
|
||||||
|
normalizedAccount?.let { authAccount ->
|
||||||
|
startAuthHandshakeFast(
|
||||||
|
authAccount.publicKey,
|
||||||
|
authAccount.privateKeyHash
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
repeat(3) { attempt ->
|
||||||
|
if (ProtocolManager.isAuthenticated()) return@launch
|
||||||
|
delay(2000L * (attempt + 1))
|
||||||
|
if (ProtocolManager.isAuthenticated()) return@launch
|
||||||
|
ProtocolManager.reconnectNowIfNeeded(
|
||||||
|
"post_auth_complete_retry_${attempt + 1}"
|
||||||
|
)
|
||||||
|
startAuthHandshakeFast(
|
||||||
|
authAccount.publicKey,
|
||||||
|
authAccount.privateKeyHash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reload accounts list
|
// Reload accounts list
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
normalizedAccount?.let {
|
||||||
|
// Синхронно помечаем текущий аккаунт активным в DataStore.
|
||||||
|
runCatching {
|
||||||
|
accountManager.setCurrentAccount(it.publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
accountInfoList = accounts.map { it.toAccountInfo() }
|
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||||
}
|
}
|
||||||
@@ -672,6 +721,7 @@ sealed class Screen {
|
|||||||
data object CrashLogs : Screen()
|
data object CrashLogs : Screen()
|
||||||
data object Biometric : Screen()
|
data object Biometric : Screen()
|
||||||
data object Appearance : Screen()
|
data object Appearance : Screen()
|
||||||
|
data object AppIcon : Screen()
|
||||||
data object QrScanner : Screen()
|
data object QrScanner : Screen()
|
||||||
data object MyQr : Screen()
|
data object MyQr : Screen()
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1081,9 @@ fun MainScreen(
|
|||||||
val isAppearanceVisible by remember {
|
val isAppearanceVisible by remember {
|
||||||
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
||||||
}
|
}
|
||||||
|
val isAppIconVisible by remember {
|
||||||
|
derivedStateOf { navStack.any { it is Screen.AppIcon } }
|
||||||
|
}
|
||||||
val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } }
|
val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } }
|
||||||
val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
|
val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
|
||||||
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
|
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
|
||||||
@@ -1437,12 +1490,25 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleTheme = onToggleTheme,
|
onToggleTheme = onToggleTheme,
|
||||||
|
onAppIconClick = { navStack = navStack + Screen.AppIcon },
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
avatarRepository = avatarRepository
|
avatarRepository = avatarRepository
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwipeBackContainer(
|
||||||
|
isVisible = isAppIconVisible,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } },
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
layer = 3
|
||||||
|
) {
|
||||||
|
com.rosetta.messenger.ui.settings.AppIconScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = isUpdatesVisible,
|
isVisible = isUpdatesVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
|
||||||
@@ -1469,9 +1535,18 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}.collectAsState(initial = 0)
|
}.collectAsState(initial = 0)
|
||||||
|
|
||||||
|
var chatSelectionActive by remember { mutableStateOf(false) }
|
||||||
|
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
|
||||||
|
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = selectedUser != null,
|
isVisible = selectedUser != null,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
|
onInterceptSwipeBack = {
|
||||||
|
if (chatSelectionActive) {
|
||||||
|
chatClearSelectionRef.value()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1,
|
layer = 1,
|
||||||
swipeEnabled = !isChatSwipeLocked,
|
swipeEnabled = !isChatSwipeLocked,
|
||||||
@@ -1516,7 +1591,9 @@ fun MainScreen(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
|
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
|
||||||
isCallActive = callUiState.isVisible,
|
isCallActive = callUiState.isVisible,
|
||||||
onOpenCallOverlay = { isCallOverlayExpanded = true }
|
onOpenCallOverlay = { isCallOverlayExpanded = true },
|
||||||
|
onSelectionModeChange = { chatSelectionActive = it },
|
||||||
|
registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ object CryptoManager {
|
|||||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||||
// расшифровке
|
// расшифровке
|
||||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||||
|
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
|
||||||
|
// и хранения гигантских plaintext в памяти.
|
||||||
|
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
|
||||||
|
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
|
||||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
|||||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||||
*/
|
*/
|
||||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||||
|
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
|
||||||
|
val cacheKey = if (useCache) "$password:$encryptedData" else null
|
||||||
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||||
val cacheKey = "$password:$encryptedData"
|
if (cacheKey != null) {
|
||||||
decryptionCache[cacheKey]?.let {
|
decryptionCache[cacheKey]?.let {
|
||||||
return it
|
return it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||||
|
|
||||||
// 🚀 Сохраняем в кэш (lock-free)
|
// 🚀 Сохраняем в кэш (lock-free)
|
||||||
if (result != null) {
|
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||||
// Ограничиваем размер кэша
|
// Ограничиваем размер кэша
|
||||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||||
// Удаляем ~10% самых старых записей
|
// Удаляем ~10% самых старых записей
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ data class Message(
|
|||||||
val replyToMessageId: String? = null
|
val replyToMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/** UI модель диалога */
|
|
||||||
data class Dialog(
|
data class Dialog(
|
||||||
val opponentKey: String,
|
val opponentKey: String,
|
||||||
val opponentTitle: String,
|
val opponentTitle: String,
|
||||||
@@ -599,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
||||||
|
|
||||||
|
// Notify listeners (ChatViewModel) that a new message was persisted
|
||||||
|
// so the chat UI reloads from DB. Without this, messages produced by
|
||||||
|
// non-input flows (e.g. CallManager's missed-call attachment) only
|
||||||
|
// appear after the user re-enters the chat.
|
||||||
|
_newMessageEvents.tryEmit(dialogKey)
|
||||||
|
|
||||||
// 📁 Для saved messages - гарантируем создание/обновление dialog
|
// 📁 Для saved messages - гарантируем создание/обновление dialog
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
val existing = dialogDao.getDialog(account, account)
|
val existing = dialogDao.getDialog(account, account)
|
||||||
@@ -1853,7 +1858,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -1910,7 +1915,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -1974,7 +1979,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -2039,4 +2044,26 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return jsonArray.toString()
|
return jsonArray.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop parity for group attachment blobs:
|
||||||
|
* old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
|
||||||
|
*/
|
||||||
|
private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
|
||||||
|
if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
|
||||||
|
|
||||||
|
val rawAttempt = runCatching {
|
||||||
|
CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
|
||||||
|
}.getOrNull()
|
||||||
|
if (rawAttempt != null) return rawAttempt
|
||||||
|
|
||||||
|
val hexKey =
|
||||||
|
groupKey.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
if (hexKey == groupKey) return null
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val BACKGROUND_BLUR_COLOR_ID =
|
val BACKGROUND_BLUR_COLOR_ID =
|
||||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||||
|
|
||||||
|
// App Icon disguise: "default", "calculator", "weather", "notes"
|
||||||
|
val APP_ICON = stringPreferencesKey("app_icon")
|
||||||
|
|
||||||
// Pinned Chats (max 3)
|
// Pinned Chats (max 3)
|
||||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||||
|
|
||||||
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
|
|||||||
return wasPinned
|
return wasPinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 APP ICON
|
||||||
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
val appIcon: Flow<String> =
|
||||||
|
context.dataStore.data.map { preferences ->
|
||||||
|
preferences[APP_ICON] ?: "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAppIcon(value: String) {
|
||||||
|
context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
|
||||||
|
}
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
// 🔕 MUTED CHATS
|
// 🔕 MUTED CHATS
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
- Исправлена расшифровка фото в группах (совместимость с Desktop v1.2.1)
|
- Полностью переработан UX записи голосовых: удержание для записи, отправка по отпусканию, Slide to cancel
|
||||||
- Исправлен статус доставки: галочки больше не откатываются на часики
|
- Пересобрана панель записи ГС в Telegram-style с новым layout, волнами и анимациями
|
||||||
- Исправлен просмотр фото из медиа-галереи профиля
|
- Добавлена и доработана анимация удаления ГС (корзина), устранены рывки и визуальные артефакты
|
||||||
- Зашифрованные ключи больше не отображаются как подпись к фото
|
- Исправлены зависания/ANR при записи и отмене голосовых (race-condition, stuck-состояния, watchdog-сценарии)
|
||||||
- Анимация удаления сообщений (плавное сжатие + fade)
|
- Исправлены скачки и наложения input-панели во время записи (включая Type message/overlay конфликты)
|
||||||
- Фильтрация пустых push-уведомлений
|
- Добавлены улучшения плеера голосовых: мини-плеер, интеграция в чат, корректная работа скоростей
|
||||||
|
- В чат-листе улучшено отображение и поведение активного воспроизведения голосовых
|
||||||
|
- Добавлена и отшлифована система выделения текста: handles, magnifier, toolbar (Copy/Select All), haptic
|
||||||
|
- Исправлены координаты и стабильность выделения текста в сложных сценариях
|
||||||
|
- Исправлена обработка reply в группах с Desktop (fallback на hex-ключ для reply blob)
|
||||||
|
- Оптимизированы тяжелые UI-сценарии: prewarm для circular reveal, ускорена анимация онбординга
|
||||||
|
- Улучшены миниатюры медиа через BlurHash и стабильность загрузки вложений
|
||||||
|
- Доработан экран звонков и related UI (включая пустой экран с Lottie-анимацией)
|
||||||
|
- Доработаны элементы профиля и сайдбара (включая обновления аккаунт-блока и действий)
|
||||||
|
- Добавлена смена иконки приложения (калькулятор, погода, заметки) через настройки
|
||||||
|
- Выполнен большой пакет фиксов по чатам/звонкам/коннекту и визуальному паритету с Telegram
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ enum class AttachmentType(val value: Int) {
|
|||||||
FILE(2), // Файл
|
FILE(2), // Файл
|
||||||
AVATAR(3), // Аватар пользователя
|
AVATAR(3), // Аватар пользователя
|
||||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||||
|
VOICE(5), // Голосовое сообщение
|
||||||
|
VIDEO_CIRCLE(6), // Видео-кружок (video note)
|
||||||
UNKNOWN(-1); // Неизвестный тип
|
UNKNOWN(-1); // Неизвестный тип
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ object CallManager {
|
|||||||
private const val TAIL_LINES = 300
|
private const val TAIL_LINES = 300
|
||||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
private const val MAX_LOG_PREFIX = 180
|
private const val MAX_LOG_PREFIX = 180
|
||||||
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
|
// Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
|
||||||
|
// slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
|
||||||
|
// the network is healthy; local jobs are a fallback when the signal is lost.
|
||||||
|
private const val INCOMING_RING_TIMEOUT_MS = 35_000L
|
||||||
|
private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
|
||||||
private const val CONNECTING_TIMEOUT_MS = 30_000L
|
private const val CONNECTING_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
@@ -127,6 +131,7 @@ object CallManager {
|
|||||||
private var protocolStateJob: Job? = null
|
private var protocolStateJob: Job? = null
|
||||||
private var disconnectResetJob: Job? = null
|
private var disconnectResetJob: Job? = null
|
||||||
private var incomingRingTimeoutJob: Job? = null
|
private var incomingRingTimeoutJob: Job? = null
|
||||||
|
private var outgoingRingTimeoutJob: Job? = null
|
||||||
private var connectingTimeoutJob: Job? = null
|
private var connectingTimeoutJob: Job? = null
|
||||||
|
|
||||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||||
@@ -290,6 +295,18 @@ object CallManager {
|
|||||||
)
|
)
|
||||||
breadcrumbState("startOutgoingCall")
|
breadcrumbState("startOutgoingCall")
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||||
|
|
||||||
|
// Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
|
||||||
|
// stop ringing after the same window the server uses (~30s + small buffer).
|
||||||
|
outgoingRingTimeoutJob?.cancel()
|
||||||
|
outgoingRingTimeoutJob = scope.launch {
|
||||||
|
delay(OUTGOING_RING_TIMEOUT_MS)
|
||||||
|
val snap = _state.value
|
||||||
|
if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
|
||||||
|
breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
|
||||||
|
resetSession(reason = "No answer", notifyPeer = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
return CallActionResult.STARTED
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +568,9 @@ object CallManager {
|
|||||||
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Callee answered before timeout — cancel outgoing ring timer
|
||||||
|
outgoingRingTimeoutJob?.cancel()
|
||||||
|
outgoingRingTimeoutJob = null
|
||||||
if (localPrivateKey == null || localPublicKey == null) {
|
if (localPrivateKey == null || localPublicKey == null) {
|
||||||
breadcrumb("SIG: ACCEPT — generating local session keys")
|
breadcrumb("SIG: ACCEPT — generating local session keys")
|
||||||
generateSessionKeys()
|
generateSessionKeys()
|
||||||
@@ -1033,9 +1053,14 @@ object CallManager {
|
|||||||
preview = durationSec.toString()
|
preview = durationSec.toString()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Capture role synchronously before the coroutine launches, because
|
||||||
|
// resetSession() sets role = null right after calling this function —
|
||||||
|
// otherwise the async check below would fall through to the callee branch.
|
||||||
|
val capturedRole = role
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (role == CallRole.CALLER) {
|
if (capturedRole == CallRole.CALLER) {
|
||||||
// CALLER: send call attachment as a message (peer will receive it)
|
// CALLER: send call attachment as a message (peer will receive it)
|
||||||
MessageRepository.getInstance(context).sendMessage(
|
MessageRepository.getInstance(context).sendMessage(
|
||||||
toPublicKey = peerPublicKey,
|
toPublicKey = peerPublicKey,
|
||||||
@@ -1082,6 +1107,8 @@ object CallManager {
|
|||||||
disconnectResetJob = null
|
disconnectResetJob = null
|
||||||
incomingRingTimeoutJob?.cancel()
|
incomingRingTimeoutJob?.cancel()
|
||||||
incomingRingTimeoutJob = null
|
incomingRingTimeoutJob = null
|
||||||
|
outgoingRingTimeoutJob?.cancel()
|
||||||
|
outgoingRingTimeoutJob = null
|
||||||
// Play end call sound, then stop all
|
// Play end call sound, then stop all
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Protocol(
|
|||||||
private const val TAG = "RosettaProtocol"
|
private const val TAG = "RosettaProtocol"
|
||||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||||
|
private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
|
||||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
||||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
@@ -182,6 +183,7 @@ class Protocol(
|
|||||||
private var lastSuccessfulConnection = 0L
|
private var lastSuccessfulConnection = 0L
|
||||||
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
||||||
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
||||||
|
private var connectingSinceMs = 0L
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@@ -385,6 +387,7 @@ class Protocol(
|
|||||||
*/
|
*/
|
||||||
fun connect() {
|
fun connect() {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
||||||
|
|
||||||
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
|
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
|
||||||
@@ -403,10 +406,20 @@ class Protocol(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||||
|
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||||
return
|
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||||
|
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
|
||||||
|
isConnecting = false
|
||||||
|
connectingSinceMs = 0L
|
||||||
|
runCatching { webSocket?.cancel() }
|
||||||
|
webSocket = null
|
||||||
|
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
|
||||||
}
|
}
|
||||||
|
|
||||||
val networkReady = isNetworkAvailable?.invoke() ?: true
|
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||||
@@ -424,6 +437,7 @@ class Protocol(
|
|||||||
|
|
||||||
// Устанавливаем флаг ПЕРЕД любыми операциями
|
// Устанавливаем флаг ПЕРЕД любыми операциями
|
||||||
isConnecting = true
|
isConnecting = true
|
||||||
|
connectingSinceMs = now
|
||||||
|
|
||||||
reconnectAttempts++
|
reconnectAttempts++
|
||||||
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
||||||
@@ -455,6 +469,7 @@ class Protocol(
|
|||||||
|
|
||||||
// Сбрасываем флаг подключения
|
// Сбрасываем флаг подключения
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
|
connectingSinceMs = 0L
|
||||||
|
|
||||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||||
// Flush queue as soon as socket is open.
|
// Flush queue as soon as socket is open.
|
||||||
@@ -500,6 +515,7 @@ class Protocol(
|
|||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
||||||
isConnecting = false // Сбрасываем флаг
|
isConnecting = false // Сбрасываем флаг
|
||||||
|
connectingSinceMs = 0L
|
||||||
handleDisconnect()
|
handleDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +527,7 @@ class Protocol(
|
|||||||
log(" Reconnect attempts: $reconnectAttempts")
|
log(" Reconnect attempts: $reconnectAttempts")
|
||||||
t.printStackTrace()
|
t.printStackTrace()
|
||||||
isConnecting = false // Сбрасываем флаг
|
isConnecting = false // Сбрасываем флаг
|
||||||
|
connectingSinceMs = 0L
|
||||||
_lastError.value = t.message
|
_lastError.value = t.message
|
||||||
handleDisconnect()
|
handleDisconnect()
|
||||||
}
|
}
|
||||||
@@ -801,6 +818,7 @@ class Protocol(
|
|||||||
log("🔌 Manual disconnect requested")
|
log("🔌 Manual disconnect requested")
|
||||||
isManuallyClosed = true
|
isManuallyClosed = true
|
||||||
isConnecting = false // Сбрасываем флаг
|
isConnecting = false // Сбрасываем флаг
|
||||||
|
connectingSinceMs = 0L
|
||||||
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
handshakeJob?.cancel()
|
handshakeJob?.cancel()
|
||||||
@@ -823,6 +841,7 @@ class Protocol(
|
|||||||
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
||||||
@@ -830,12 +849,22 @@ class Protocol(
|
|||||||
|
|
||||||
if (!hasCredentials) return
|
if (!hasCredentials) return
|
||||||
|
|
||||||
if (
|
if (currentState == ProtocolState.CONNECTING && isConnecting) {
|
||||||
|
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||||
|
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
|
||||||
|
isConnecting = false
|
||||||
|
connectingSinceMs = 0L
|
||||||
|
runCatching { webSocket?.cancel() }
|
||||||
|
webSocket = null
|
||||||
|
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
|
||||||
|
} else if (
|
||||||
currentState == ProtocolState.AUTHENTICATED ||
|
currentState == ProtocolState.AUTHENTICATED ||
|
||||||
currentState == ProtocolState.HANDSHAKING ||
|
currentState == ProtocolState.HANDSHAKING ||
|
||||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||||
currentState == ProtocolState.CONNECTED ||
|
currentState == ProtocolState.CONNECTED
|
||||||
(currentState == ProtocolState.CONNECTING && isConnecting)
|
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
|||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||||
import com.rosetta.messenger.ui.chats.components.*
|
import com.rosetta.messenger.ui.chats.components.*
|
||||||
|
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
|
||||||
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
|
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
|
||||||
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
||||||
import com.rosetta.messenger.ui.chats.input.*
|
import com.rosetta.messenger.ui.chats.input.*
|
||||||
@@ -324,7 +325,9 @@ fun ChatDetailScreen(
|
|||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onImageViewerChanged: (Boolean) -> Unit = {},
|
onImageViewerChanged: (Boolean) -> Unit = {},
|
||||||
isCallActive: Boolean = false,
|
isCallActive: Boolean = false,
|
||||||
onOpenCallOverlay: () -> Unit = {}
|
onOpenCallOverlay: () -> Unit = {},
|
||||||
|
onSelectionModeChange: (Boolean) -> Unit = {},
|
||||||
|
registerClearSelection: (() -> Unit) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -389,11 +392,23 @@ fun ChatDetailScreen(
|
|||||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||||
|
|
||||||
|
// Notify parent about selection mode changes so it can intercept swipe-back
|
||||||
|
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
|
||||||
|
// Register selection-clear callback so parent can cancel selection on swipe-back
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
registerClearSelection { selectedMessages = emptySet() }
|
||||||
|
onDispose { registerClearSelection {} }
|
||||||
|
}
|
||||||
// После long press AndroidView текста может прислать tap на отпускание.
|
// После long press AndroidView текста может прислать tap на отпускание.
|
||||||
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
||||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
// 🔤 TEXT SELECTION - Telegram-style character-level selection
|
||||||
|
val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() }
|
||||||
|
LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) }
|
||||||
|
|
||||||
// 💬 MESSAGE CONTEXT MENU STATE
|
// 💬 MESSAGE CONTEXT MENU STATE
|
||||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
@@ -437,11 +452,29 @@ fun ChatDetailScreen(
|
|||||||
showEmojiPicker = false
|
showEmojiPicker = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
// 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
|
||||||
val hideKeyboardAndBack: () -> Unit = {
|
val hideKeyboardAndBack: () -> Unit = {
|
||||||
hideInputOverlays()
|
hideInputOverlays()
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
// 🔥 Поведение как у нативного Android back:
|
||||||
|
// сначала закрываем IME/emoji, и только следующим back выходим из чата.
|
||||||
|
val handleBackWithInputPriority: () -> Unit = {
|
||||||
|
val imeVisible =
|
||||||
|
androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||||
|
?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true
|
||||||
|
val hasInputOverlay =
|
||||||
|
showEmojiPicker ||
|
||||||
|
coordinator.isEmojiBoxVisible ||
|
||||||
|
coordinator.isKeyboardVisible ||
|
||||||
|
imeVisible
|
||||||
|
|
||||||
|
if (hasInputOverlay) {
|
||||||
|
hideInputOverlays()
|
||||||
|
} else {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Определяем это Saved Messages или обычный чат
|
// Определяем это Saved Messages или обычный чат
|
||||||
val isSavedMessages = user.publicKey == currentUserPublicKey
|
val isSavedMessages = user.publicKey == currentUserPublicKey
|
||||||
@@ -611,6 +644,8 @@ fun ChatDetailScreen(
|
|||||||
showImageViewer,
|
showImageViewer,
|
||||||
showMediaPicker,
|
showMediaPicker,
|
||||||
showEmojiPicker,
|
showEmojiPicker,
|
||||||
|
textSelectionHelper.isActive,
|
||||||
|
textSelectionHelper.movingHandle,
|
||||||
pendingCameraPhotoUri,
|
pendingCameraPhotoUri,
|
||||||
pendingGalleryImages,
|
pendingGalleryImages,
|
||||||
showInAppCamera,
|
showInAppCamera,
|
||||||
@@ -620,6 +655,8 @@ fun ChatDetailScreen(
|
|||||||
showImageViewer ||
|
showImageViewer ||
|
||||||
showMediaPicker ||
|
showMediaPicker ||
|
||||||
showEmojiPicker ||
|
showEmojiPicker ||
|
||||||
|
textSelectionHelper.isActive ||
|
||||||
|
textSelectionHelper.movingHandle ||
|
||||||
pendingCameraPhotoUri != null ||
|
pendingCameraPhotoUri != null ||
|
||||||
pendingGalleryImages.isNotEmpty() ||
|
pendingGalleryImages.isNotEmpty() ||
|
||||||
showInAppCamera ||
|
showInAppCamera ||
|
||||||
@@ -838,6 +875,7 @@ fun ChatDetailScreen(
|
|||||||
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||||
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||||
{ messageId, canSelect ->
|
{ messageId, canSelect ->
|
||||||
|
textSelectionHelper.clear()
|
||||||
if (canSelect && !selectedMessages.contains(messageId)) {
|
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||||
selectedMessages = selectedMessages + messageId
|
selectedMessages = selectedMessages + messageId
|
||||||
}
|
}
|
||||||
@@ -886,6 +924,13 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔤 Сброс текстового выделения при скролле
|
||||||
|
LaunchedEffect(listState.isScrollInProgress) {
|
||||||
|
if (listState.isScrollInProgress && textSelectionHelper.isActive) {
|
||||||
|
textSelectionHelper.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
||||||
val displayReplyMessages =
|
val displayReplyMessages =
|
||||||
remember(replyMessages, messages) {
|
remember(replyMessages, messages) {
|
||||||
@@ -1325,10 +1370,10 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// 🔥 Обработка системной кнопки назад
|
// 🔥 Обработка системной кнопки назад
|
||||||
BackHandler {
|
BackHandler {
|
||||||
if (isInChatSearchMode) {
|
when {
|
||||||
closeInChatSearch()
|
isSelectionMode -> selectedMessages = emptySet()
|
||||||
} else {
|
isInChatSearchMode -> closeInChatSearch()
|
||||||
hideKeyboardAndBack()
|
else -> handleBackWithInputPriority()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1843,7 +1888,7 @@ fun ChatDetailScreen(
|
|||||||
Box {
|
Box {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick =
|
onClick =
|
||||||
hideKeyboardAndBack,
|
handleBackWithInputPriority,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(
|
Modifier.size(
|
||||||
40.dp
|
40.dp
|
||||||
@@ -2289,6 +2334,36 @@ fun ChatDetailScreen(
|
|||||||
avatarRepository = avatarRepository
|
avatarRepository = avatarRepository
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Voice mini player — shown right under the chat header when audio is playing
|
||||||
|
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||||
|
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||||
|
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||||
|
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||||
|
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !playingVoiceAttachmentId.isNullOrBlank(),
|
||||||
|
enter = expandVertically(
|
||||||
|
animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||||
|
expandFrom = Alignment.Top
|
||||||
|
) + fadeIn(animationSpec = tween(220)),
|
||||||
|
exit = shrinkVertically(
|
||||||
|
animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||||
|
shrinkTowards = Alignment.Top
|
||||||
|
) + fadeOut(animationSpec = tween(180))
|
||||||
|
) {
|
||||||
|
val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
|
||||||
|
val time = playingVoiceTimeLabel.trim()
|
||||||
|
val voiceTitle = if (time.isBlank()) sender else "$sender at $time"
|
||||||
|
VoiceTopMiniPlayer(
|
||||||
|
title = voiceTitle,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
isPlaying = isVoicePlaybackRunning,
|
||||||
|
speed = voicePlaybackSpeed,
|
||||||
|
onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() },
|
||||||
|
onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() },
|
||||||
|
onClose = { VoicePlaybackCoordinator.stop() }
|
||||||
|
)
|
||||||
|
}
|
||||||
} // Закрытие Column topBar
|
} // Закрытие Column topBar
|
||||||
},
|
},
|
||||||
containerColor = backgroundColor, // Фон всего чата
|
containerColor = backgroundColor, // Фон всего чата
|
||||||
@@ -2679,6 +2754,20 @@ fun ChatDetailScreen(
|
|||||||
isSendingMessage = false
|
isSendingMessage = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSendVoiceMessage = { voiceHex, durationSec, waves ->
|
||||||
|
isSendingMessage = true
|
||||||
|
viewModel.sendVoiceMessage(
|
||||||
|
voiceHex = voiceHex,
|
||||||
|
durationSec = durationSec,
|
||||||
|
waves = waves
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
delay(120)
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
delay(220)
|
||||||
|
isSendingMessage = false
|
||||||
|
}
|
||||||
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
@@ -3011,6 +3100,7 @@ fun ChatDetailScreen(
|
|||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
userScrollEnabled = !textSelectionHelper.movingHandle,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.nestedScroll(
|
.nestedScroll(
|
||||||
@@ -3150,6 +3240,8 @@ fun ChatDetailScreen(
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message =
|
message =
|
||||||
message,
|
message,
|
||||||
|
textSelectionHelper =
|
||||||
|
textSelectionHelper,
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
hasWallpaper =
|
hasWallpaper =
|
||||||
@@ -3630,6 +3722,11 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 🔤 Text selection overlay
|
||||||
|
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
|
||||||
|
helper = textSelectionHelper,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3691,16 +3788,32 @@ fun ChatDetailScreen(
|
|||||||
onMediaSelected = { selectedMedia, caption ->
|
onMediaSelected = { selectedMedia, caption ->
|
||||||
val imageUris =
|
val imageUris =
|
||||||
selectedMedia.filter { !it.isVideo }.map { it.uri }
|
selectedMedia.filter { !it.isVideo }.map { it.uri }
|
||||||
if (imageUris.isNotEmpty()) {
|
val videoUris =
|
||||||
|
selectedMedia.filter { it.isVideo }.map { it.uri }
|
||||||
|
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
inputFocusTrigger++
|
inputFocusTrigger++
|
||||||
viewModel.sendImageGroupFromUris(imageUris, caption)
|
if (imageUris.isNotEmpty()) {
|
||||||
|
viewModel.sendImageGroupFromUris(
|
||||||
|
imageUris,
|
||||||
|
caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (videoUris.isNotEmpty()) {
|
||||||
|
videoUris.forEach { uri ->
|
||||||
|
viewModel.sendVideoCircleFromUri(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
inputFocusTrigger++
|
inputFocusTrigger++
|
||||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
if (mediaItem.isVideo) {
|
||||||
|
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||||
|
} else {
|
||||||
|
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpenCamera = {
|
onOpenCamera = {
|
||||||
val imm =
|
val imm =
|
||||||
@@ -3792,16 +3905,32 @@ fun ChatDetailScreen(
|
|||||||
onMediaSelected = { selectedMedia, caption ->
|
onMediaSelected = { selectedMedia, caption ->
|
||||||
val imageUris =
|
val imageUris =
|
||||||
selectedMedia.filter { !it.isVideo }.map { it.uri }
|
selectedMedia.filter { !it.isVideo }.map { it.uri }
|
||||||
if (imageUris.isNotEmpty()) {
|
val videoUris =
|
||||||
|
selectedMedia.filter { it.isVideo }.map { it.uri }
|
||||||
|
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
inputFocusTrigger++
|
inputFocusTrigger++
|
||||||
viewModel.sendImageGroupFromUris(imageUris, caption)
|
if (imageUris.isNotEmpty()) {
|
||||||
|
viewModel.sendImageGroupFromUris(
|
||||||
|
imageUris,
|
||||||
|
caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (videoUris.isNotEmpty()) {
|
||||||
|
videoUris.forEach { uri ->
|
||||||
|
viewModel.sendVideoCircleFromUri(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
inputFocusTrigger++
|
inputFocusTrigger++
|
||||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
if (mediaItem.isVideo) {
|
||||||
|
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||||
|
} else {
|
||||||
|
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpenCamera = {
|
onOpenCamera = {
|
||||||
val imm =
|
val imm =
|
||||||
@@ -4258,6 +4387,7 @@ private fun ChatInputBarSection(
|
|||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
isSavedMessages: Boolean,
|
isSavedMessages: Boolean,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
@@ -4295,6 +4425,7 @@ private fun ChatInputBarSection(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSend = onSend,
|
onSend = onSend,
|
||||||
|
onSendVoiceMessage = onSendVoiceMessage,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
@@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
when (parseAttachmentType(attachment)) {
|
when (parseAttachmentType(attachment)) {
|
||||||
AttachmentType.IMAGE,
|
AttachmentType.IMAGE,
|
||||||
AttachmentType.FILE,
|
AttachmentType.FILE,
|
||||||
AttachmentType.AVATAR -> {
|
AttachmentType.AVATAR,
|
||||||
|
AttachmentType.VIDEO_CIRCLE -> {
|
||||||
hasMediaAttachment = true
|
hasMediaAttachment = true
|
||||||
if (attachment.optString("localUri", "").isNotBlank()) {
|
if (attachment.optString("localUri", "").isNotBlank()) {
|
||||||
// Локальный URI ещё есть => загрузка/подготовка не завершена.
|
// Локальный URI ещё есть => загрузка/подготовка не завершена.
|
||||||
@@ -853,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
isOutgoing = fm.isOutgoing,
|
isOutgoing = fm.isOutgoing,
|
||||||
publicKey = fm.senderPublicKey,
|
publicKey = fm.senderPublicKey,
|
||||||
senderName = fm.senderName,
|
senderName = fm.senderName,
|
||||||
attachments = fm.attachments
|
attachments = fm.attachments,
|
||||||
|
chachaKeyPlainHex = fm.chachaKeyPlain
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = true
|
_isForwardMode.value = true
|
||||||
@@ -1625,6 +1629,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
"file" -> AttachmentType.FILE.value
|
"file" -> AttachmentType.FILE.value
|
||||||
"avatar" -> AttachmentType.AVATAR.value
|
"avatar" -> AttachmentType.AVATAR.value
|
||||||
"call" -> AttachmentType.CALL.value
|
"call" -> AttachmentType.CALL.value
|
||||||
|
"voice" -> AttachmentType.VOICE.value
|
||||||
|
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
|
||||||
|
AttachmentType.VIDEO_CIRCLE.value
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1792,9 +1799,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
|
||||||
if ((effectiveType == AttachmentType.IMAGE ||
|
if ((effectiveType == AttachmentType.IMAGE ||
|
||||||
effectiveType == AttachmentType.AVATAR) &&
|
effectiveType == AttachmentType.AVATAR ||
|
||||||
|
effectiveType == AttachmentType.VOICE ||
|
||||||
|
effectiveType == AttachmentType.VIDEO_CIRCLE) &&
|
||||||
blob.isEmpty() &&
|
blob.isEmpty() &&
|
||||||
attachmentId.isNotEmpty()
|
attachmentId.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
@@ -1872,6 +1881,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val forwardedMessages: List<ReplyData> = emptyList()
|
val forwardedMessages: List<ReplyData> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun replyLog(msg: String) {
|
||||||
|
try {
|
||||||
|
val ctx = getApplication<android.app.Application>()
|
||||||
|
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
||||||
|
val dir = java.io.File(ctx.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n")
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun parseReplyFromAttachments(
|
private suspend fun parseReplyFromAttachments(
|
||||||
attachmentsJson: String,
|
attachmentsJson: String,
|
||||||
isFromMe: Boolean,
|
isFromMe: Boolean,
|
||||||
@@ -1887,26 +1906,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val attachments = JSONArray(attachmentsJson)
|
val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null
|
||||||
|
|
||||||
for (i in 0 until attachments.length()) {
|
for (i in 0 until attachments.length()) {
|
||||||
val attachment = attachments.getJSONObject(i)
|
val attachment = attachments.getJSONObject(i)
|
||||||
val type = attachment.optInt("type", 0)
|
val type = parseAttachmentType(attachment)
|
||||||
|
|
||||||
// MESSAGES = 1 (цитата)
|
// MESSAGES = 1 (цитата)
|
||||||
if (type == 1) {
|
if (type == AttachmentType.MESSAGES) {
|
||||||
|
replyLog("=== PARSE REPLY: isFromMe=$isFromMe, hasGroup=${groupPassword != null}, chachaKey=${chachaKey.take(12)}, hasPlainKey=${plainKeyAndNonce != null} ===")
|
||||||
|
|
||||||
// Данные могут быть в blob или preview
|
// Данные могут быть в blob или preview
|
||||||
var dataJson = attachment.optString("blob", "")
|
var dataJson = attachment.optString("blob", "")
|
||||||
|
|
||||||
if (dataJson.isEmpty()) {
|
if (dataJson.isEmpty()) {
|
||||||
dataJson = attachment.optString("preview", "")
|
dataJson = attachment.optString("preview", "")
|
||||||
|
replyLog(" blob empty, using preview")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataJson.isEmpty()) {
|
if (dataJson.isEmpty()) {
|
||||||
|
replyLog(" BOTH empty → skip")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyLog(" dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'")
|
||||||
|
|
||||||
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат
|
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат
|
||||||
// "iv:ciphertext"
|
// "iv:ciphertext"
|
||||||
val colonCount = dataJson.count { it == ':' }
|
val colonCount = dataJson.count { it == ':' }
|
||||||
@@ -1914,21 +1938,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
|
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
|
||||||
val privateKey = myPrivateKey
|
val privateKey = myPrivateKey
|
||||||
var decryptionSuccess = false
|
var decryptionSuccess = false
|
||||||
|
replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...")
|
||||||
|
|
||||||
// 🔥 Способ 0: Группа — blob шифруется ключом группы
|
// 🔥 Способ 0: Группа — blob шифруется ключом группы
|
||||||
if (groupPassword != null) {
|
if (groupPassword != null && !decryptionSuccess) {
|
||||||
|
replyLog(" [0] group raw key (len=${groupPassword.length})")
|
||||||
try {
|
try {
|
||||||
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
|
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
|
||||||
if (decrypted != null) {
|
if (decrypted != null) {
|
||||||
dataJson = decrypted
|
dataJson = decrypted
|
||||||
decryptionSuccess = true
|
decryptionSuccess = true
|
||||||
|
replyLog(" [0] OK raw key")
|
||||||
|
} else {
|
||||||
|
replyLog(" [0] raw key → null")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (e: Exception) { replyLog(" [0] raw key EXCEPTION: ${e.message}") }
|
||||||
|
// Fallback: Desktop v1.2.1+ шифрует hex-версией ключа
|
||||||
|
if (!decryptionSuccess) {
|
||||||
|
try {
|
||||||
|
val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
replyLog(" [0] trying hex key (len=${hexKey.length})")
|
||||||
|
val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey)
|
||||||
|
if (decrypted != null) {
|
||||||
|
dataJson = decrypted
|
||||||
|
decryptionSuccess = true
|
||||||
|
replyLog(" [0] OK hex key")
|
||||||
|
} else {
|
||||||
|
replyLog(" [0] hex key → null")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { replyLog(" [0] hex key EXCEPTION: ${e.message}") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
|
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом
|
||||||
// сообщений)
|
if (privateKey != null && !decryptionSuccess) {
|
||||||
if (privateKey != null) {
|
replyLog(" [1] private key")
|
||||||
try {
|
try {
|
||||||
val decrypted =
|
val decrypted =
|
||||||
CryptoManager.decryptWithPassword(dataJson, privateKey)
|
CryptoManager.decryptWithPassword(dataJson, privateKey)
|
||||||
@@ -1998,26 +2043,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyLog(" FINAL: decryptionSuccess=$decryptionSuccess")
|
||||||
if (!decryptionSuccess) {
|
if (!decryptionSuccess) {
|
||||||
|
replyLog(" ALL METHODS FAILED → skip")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {}
|
} else {
|
||||||
|
replyLog(" NOT encrypted (no iv:cipher format), treating as plain JSON")
|
||||||
|
}
|
||||||
|
|
||||||
val messagesArray =
|
val messagesArray =
|
||||||
try {
|
try {
|
||||||
JSONArray(dataJson)
|
JSONArray(dataJson)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
replyLog(" JSON parse FAILED: ${e.message?.take(50)}")
|
||||||
|
replyLog(" dataJson preview: '${dataJson.take(80)}'")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyLog(" JSON OK: ${messagesArray.length()} messages")
|
||||||
if (messagesArray.length() > 0) {
|
if (messagesArray.length() > 0) {
|
||||||
val account = myPublicKey ?: return null
|
val account = myPublicKey ?: return null
|
||||||
val dialogKey = getDialogKey(account, opponentKey ?: "")
|
val dialogKey = getDialogKey(account, opponentKey ?: "")
|
||||||
|
|
||||||
// Check if this is a forwarded set or a regular reply
|
|
||||||
// Desktop doesn't set "forwarded" flag, but sends multiple messages in the array
|
|
||||||
val firstMsg = messagesArray.getJSONObject(0)
|
val firstMsg = messagesArray.getJSONObject(0)
|
||||||
val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1
|
val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1
|
||||||
|
replyLog(" isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}")
|
||||||
|
|
||||||
if (isForwardedSet) {
|
if (isForwardedSet) {
|
||||||
// 🔥 Parse ALL forwarded messages (desktop parity)
|
// 🔥 Parse ALL forwarded messages (desktop parity)
|
||||||
@@ -2110,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
forwardedList.add(ReplyData(
|
forwardedList.add(ReplyData(
|
||||||
messageId = fwdMessageId,
|
messageId = fwdMessageId,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
text = fwdText,
|
text = resolveReplyPreviewText(fwdText, fwdAttachments),
|
||||||
isFromMe = fwdIsFromMe,
|
isFromMe = fwdIsFromMe,
|
||||||
isForwarded = true,
|
isForwarded = true,
|
||||||
forwardedFromName = senderDisplayName,
|
forwardedFromName = senderDisplayName,
|
||||||
@@ -2120,6 +2171,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
chachaKeyPlainHex = fwdChachaKeyPlain
|
chachaKeyPlainHex = fwdChachaKeyPlain
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
replyLog(" RESULT: forwarded ${forwardedList.size} messages")
|
||||||
return ParsedReplyResult(
|
return ParsedReplyResult(
|
||||||
replyData = forwardedList.firstOrNull(),
|
replyData = forwardedList.firstOrNull(),
|
||||||
forwardedMessages = forwardedList
|
forwardedMessages = forwardedList
|
||||||
@@ -2135,13 +2187,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val senderNameFromJson = replyMessage.optString("senderName", "")
|
val senderNameFromJson = replyMessage.optString("senderName", "")
|
||||||
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
|
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
|
||||||
|
|
||||||
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
|
// 🔥 Detect forward:
|
||||||
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
|
// - explicit "forwarded" flag always wins
|
||||||
// both myPublicKey and opponentKey — it's a forwarded message from someone else
|
// - third-party heuristic applies ONLY for direct dialogs
|
||||||
val isFromThirdParty = replyPublicKey.isNotEmpty() &&
|
// (in groups reply author is naturally "third-party", and that must remain a reply)
|
||||||
|
val isGroupContext = isGroupDialogKey(opponentKey ?: "") || isGroupDialogKey(dialogKey)
|
||||||
|
val isFromThirdPartyDirect = !isGroupContext &&
|
||||||
|
replyPublicKey.isNotEmpty() &&
|
||||||
replyPublicKey != myPublicKey &&
|
replyPublicKey != myPublicKey &&
|
||||||
replyPublicKey != opponentKey
|
replyPublicKey != opponentKey
|
||||||
val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty
|
val isForwarded =
|
||||||
|
replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect
|
||||||
|
|
||||||
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
||||||
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
||||||
@@ -2291,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = realMessageId,
|
messageId = realMessageId,
|
||||||
senderName = resolvedSenderName,
|
senderName = resolvedSenderName,
|
||||||
text = replyText,
|
text = resolveReplyPreviewText(replyText, originalAttachments),
|
||||||
isFromMe = isReplyFromMe,
|
isFromMe = isReplyFromMe,
|
||||||
isForwarded = isForwarded,
|
isForwarded = isForwarded,
|
||||||
forwardedFromName = forwardFromDisplay,
|
forwardedFromName = forwardFromDisplay,
|
||||||
@@ -2308,11 +2364,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
|
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
|
||||||
// so it renders with "Forwarded from" header (like multi-forward)
|
// so it renders with "Forwarded from" header (like multi-forward)
|
||||||
if (isForwarded) {
|
if (isForwarded) {
|
||||||
|
replyLog(" RESULT: single forward from=${result.senderName}")
|
||||||
return ParsedReplyResult(
|
return ParsedReplyResult(
|
||||||
replyData = result,
|
replyData = result,
|
||||||
forwardedMessages = listOf(result)
|
forwardedMessages = listOf(result)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
replyLog(" RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'")
|
||||||
return ParsedReplyResult(replyData = result)
|
return ParsedReplyResult(replyData = result)
|
||||||
} else {}
|
} else {}
|
||||||
} else {}
|
} else {}
|
||||||
@@ -2444,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveReplyPreviewText(
|
||||||
|
text: String,
|
||||||
|
attachments: List<MessageAttachment>
|
||||||
|
): String {
|
||||||
|
if (text.isNotBlank()) return text
|
||||||
|
return when {
|
||||||
|
attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||||
|
attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||||
|
else -> text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
|
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
|
||||||
* правильного отображения цитаты
|
* правильного отображения цитаты
|
||||||
@@ -2458,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
msg.senderPublicKey.trim().ifEmpty {
|
msg.senderPublicKey.trim().ifEmpty {
|
||||||
if (msg.isOutgoing) sender else opponent
|
if (msg.isOutgoing) sender else opponent
|
||||||
}
|
}
|
||||||
|
val resolvedAttachments =
|
||||||
|
msg.attachments
|
||||||
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
ReplyMessage(
|
ReplyMessage(
|
||||||
messageId = msg.id,
|
messageId = msg.id,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
timestamp = msg.timestamp.time,
|
timestamp = msg.timestamp.time,
|
||||||
isOutgoing = msg.isOutgoing,
|
isOutgoing = msg.isOutgoing,
|
||||||
publicKey = resolvedPublicKey,
|
publicKey = resolvedPublicKey,
|
||||||
senderName = msg.senderName,
|
senderName = msg.senderName,
|
||||||
attachments =
|
attachments = resolvedAttachments,
|
||||||
msg.attachments
|
|
||||||
.filter { it.type != AttachmentType.MESSAGES },
|
|
||||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2485,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
msg.senderPublicKey.trim().ifEmpty {
|
msg.senderPublicKey.trim().ifEmpty {
|
||||||
if (msg.isOutgoing) sender else opponent
|
if (msg.isOutgoing) sender else opponent
|
||||||
}
|
}
|
||||||
|
val resolvedAttachments =
|
||||||
|
msg.attachments
|
||||||
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
ReplyMessage(
|
ReplyMessage(
|
||||||
messageId = msg.id,
|
messageId = msg.id,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
timestamp = msg.timestamp.time,
|
timestamp = msg.timestamp.time,
|
||||||
isOutgoing = msg.isOutgoing,
|
isOutgoing = msg.isOutgoing,
|
||||||
publicKey = resolvedPublicKey,
|
publicKey = resolvedPublicKey,
|
||||||
senderName = msg.senderName,
|
senderName = msg.senderName,
|
||||||
attachments =
|
attachments = resolvedAttachments,
|
||||||
msg.attachments
|
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||||
.filter { it.type != AttachmentType.MESSAGES }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = true
|
_isForwardMode.value = true
|
||||||
@@ -2517,6 +2590,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
||||||
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
|
message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
|
||||||
|
message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video message"
|
||||||
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||||
message.replyData != null -> "Reply"
|
message.replyData != null -> "Reply"
|
||||||
else -> "Pinned message"
|
else -> "Pinned message"
|
||||||
@@ -2883,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = firstReply.messageId,
|
messageId = firstReply.messageId,
|
||||||
senderName = firstReplySenderName,
|
senderName = firstReplySenderName,
|
||||||
text = firstReply.text,
|
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
|
||||||
isFromMe = firstReply.isOutgoing,
|
isFromMe = firstReply.isOutgoing,
|
||||||
isForwarded = isForward,
|
isForwarded = isForward,
|
||||||
forwardedFromName =
|
forwardedFromName =
|
||||||
@@ -2913,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = msg.messageId,
|
messageId = msg.messageId,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
isFromMe = msg.isOutgoing,
|
isFromMe = msg.isOutgoing,
|
||||||
isForwarded = true,
|
isForwarded = true,
|
||||||
forwardedFromName = senderDisplayName,
|
forwardedFromName = senderDisplayName,
|
||||||
@@ -3084,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (isForwardToSend) {
|
if (isForwardToSend) {
|
||||||
put("forwarded", true)
|
put("forwarded", true)
|
||||||
put("senderName", msg.senderName)
|
put("senderName", msg.senderName)
|
||||||
|
if (msg.chachaKeyPlainHex.isNotEmpty()) {
|
||||||
|
put("chacha_key_plain", msg.chachaKeyPlainHex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replyJsonArray.put(replyJson)
|
replyJsonArray.put(replyJson)
|
||||||
@@ -4757,6 +4835,530 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class VideoCircleMeta(
|
||||||
|
val durationSec: Int,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val mimeType: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun bytesToHex(bytes: ByteArray): String {
|
||||||
|
val hexChars = "0123456789abcdef".toCharArray()
|
||||||
|
val output = CharArray(bytes.size * 2)
|
||||||
|
var index = 0
|
||||||
|
bytes.forEach { byte ->
|
||||||
|
val value = byte.toInt() and 0xFF
|
||||||
|
output[index++] = hexChars[value ushr 4]
|
||||||
|
output[index++] = hexChars[value and 0x0F]
|
||||||
|
}
|
||||||
|
return String(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveVideoCircleMeta(
|
||||||
|
application: Application,
|
||||||
|
videoUri: android.net.Uri
|
||||||
|
): VideoCircleMeta {
|
||||||
|
var durationSec = 1
|
||||||
|
var width = 0
|
||||||
|
var height = 0
|
||||||
|
|
||||||
|
val mimeType =
|
||||||
|
application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank {
|
||||||
|
val ext =
|
||||||
|
MimeTypeMap.getFileExtensionFromUrl(videoUri.toString())
|
||||||
|
?.lowercase(Locale.ROOT)
|
||||||
|
?: ""
|
||||||
|
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
retriever.setDataSource(application, videoUri)
|
||||||
|
val durationMs =
|
||||||
|
retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||||
|
)
|
||||||
|
?.toLongOrNull()
|
||||||
|
?: 0L
|
||||||
|
val rawWidth =
|
||||||
|
retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
|
||||||
|
)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
val rawHeight =
|
||||||
|
retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
|
||||||
|
)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
val rotation =
|
||||||
|
retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
|
||||||
|
)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
retriever.release()
|
||||||
|
|
||||||
|
durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
|
||||||
|
val rotated = rotation == 90 || rotation == 270
|
||||||
|
width = if (rotated) rawHeight else rawWidth
|
||||||
|
height = if (rotated) rawWidth else rawHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoCircleMeta(
|
||||||
|
durationSec = durationSec,
|
||||||
|
width = width.coerceAtLeast(0),
|
||||||
|
height = height.coerceAtLeast(0),
|
||||||
|
mimeType = mimeType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun encodeVideoUriToHex(
|
||||||
|
application: Application,
|
||||||
|
videoUri: android.net.Uri
|
||||||
|
): String? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
application.contentResolver.openInputStream(videoUri)?.use { stream ->
|
||||||
|
val bytes = stream.readBytes()
|
||||||
|
if (bytes.isEmpty()) null else bytesToHex(bytes)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎥 Отправка видео-кружка (video note) из URI.
|
||||||
|
* Использует такой же transport + шифрование пайплайн, как voice attachment.
|
||||||
|
*/
|
||||||
|
fun sendVideoCircleFromUri(videoUri: android.net.Uri) {
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
|
||||||
|
if (recipient == null || sender == null || privateKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) }
|
||||||
|
.getOrDefault(0L)
|
||||||
|
val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L
|
||||||
|
if (fileSize > 0L && fileSize > maxBytes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val attachmentId = "video_circle_$timestamp"
|
||||||
|
val meta = resolveVideoCircleMeta(context, videoUri)
|
||||||
|
val preview = "${meta.durationSec}::${meta.mimeType}"
|
||||||
|
|
||||||
|
val optimisticMessage =
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = "",
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.VIDEO_CIRCLE,
|
||||||
|
preview = preview,
|
||||||
|
width = meta.width,
|
||||||
|
height = meta.height,
|
||||||
|
localUri = videoUri.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
addMessageSafely(optimisticMessage)
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
backgroundUploadScope.launch {
|
||||||
|
try {
|
||||||
|
val optimisticAttachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.VIDEO_CIRCLE.value)
|
||||||
|
put("preview", preview)
|
||||||
|
put("blob", "")
|
||||||
|
put("width", meta.width)
|
||||||
|
put("height", meta.height)
|
||||||
|
put("localUri", videoUri.toString())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
saveMessageToDatabase(
|
||||||
|
messageId = messageId,
|
||||||
|
text = "",
|
||||||
|
encryptedContent = "",
|
||||||
|
encryptedKey = "",
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
delivered = 0,
|
||||||
|
attachmentsJson = optimisticAttachmentsJson,
|
||||||
|
opponentPublicKey = recipient
|
||||||
|
)
|
||||||
|
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val videoHex = encodeVideoUriToHex(context, videoUri)
|
||||||
|
if (videoHex.isNullOrBlank()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
sendVideoCircleMessageInternal(
|
||||||
|
messageId = messageId,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
timestamp = timestamp,
|
||||||
|
videoHex = videoHex,
|
||||||
|
preview = preview,
|
||||||
|
width = meta.width,
|
||||||
|
height = meta.height,
|
||||||
|
recipient = recipient,
|
||||||
|
sender = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
} finally {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendVideoCircleMessageInternal(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
timestamp: Long,
|
||||||
|
videoHex: String,
|
||||||
|
preview: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
recipient: String,
|
||||||
|
sender: String,
|
||||||
|
privateKey: String
|
||||||
|
) {
|
||||||
|
var packetSentToProtocol = false
|
||||||
|
try {
|
||||||
|
val application = getApplication<Application>()
|
||||||
|
|
||||||
|
val encryptionContext =
|
||||||
|
buildEncryptionContext(
|
||||||
|
plaintext = "",
|
||||||
|
recipient = recipient,
|
||||||
|
privateKey = privateKey
|
||||||
|
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||||
|
val encryptedContent = encryptionContext.encryptedContent
|
||||||
|
val encryptedKey = encryptionContext.encryptedKey
|
||||||
|
val aesChachaKey = encryptionContext.aesChachaKey
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext)
|
||||||
|
|
||||||
|
val isSavedMessages = (sender == recipient)
|
||||||
|
val uploadTag =
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
TransportManager.uploadFile(attachmentId, encryptedVideoBlob)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val attachmentTransportServer =
|
||||||
|
if (uploadTag.isNotEmpty()) {
|
||||||
|
TransportManager.getTransportServer().orEmpty()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val videoAttachment =
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.VIDEO_CIRCLE,
|
||||||
|
preview = preview,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
transportTag = uploadTag,
|
||||||
|
transportServer = attachmentTransportServer
|
||||||
|
)
|
||||||
|
|
||||||
|
val packet =
|
||||||
|
PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipient
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.aesChachaKey = aesChachaKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = listOf(videoAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
packetSentToProtocol = true
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = application,
|
||||||
|
blob = videoHex,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.VIDEO_CIRCLE.value)
|
||||||
|
put("preview", preview)
|
||||||
|
put("blob", "")
|
||||||
|
put("width", width)
|
||||||
|
put("height", height)
|
||||||
|
put("transportTag", uploadTag)
|
||||||
|
put("transportServer", attachmentTransportServer)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
updateMessageStatusAndAttachmentsInDb(
|
||||||
|
messageId = messageId,
|
||||||
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
|
attachmentsJson = attachmentsJson
|
||||||
|
)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
updateMessageAttachments(messageId, null)
|
||||||
|
}
|
||||||
|
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (packetSentToProtocol) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎙️ Отправка голосового сообщения.
|
||||||
|
* blob хранится как HEX строка opus/webm байт (desktop parity).
|
||||||
|
* preview формат: "<durationSec>::<wave1,wave2,...>"
|
||||||
|
*/
|
||||||
|
fun sendVoiceMessage(
|
||||||
|
voiceHex: String,
|
||||||
|
durationSec: Int,
|
||||||
|
waves: List<Float>
|
||||||
|
) {
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
|
||||||
|
if (recipient == null || sender == null || privateKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedVoiceHex = voiceHex.trim()
|
||||||
|
if (normalizedVoiceHex.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedDuration = durationSec.coerceAtLeast(1)
|
||||||
|
val normalizedWaves =
|
||||||
|
waves.asSequence()
|
||||||
|
.map { it.coerceIn(0f, 1f) }
|
||||||
|
.take(120)
|
||||||
|
.toList()
|
||||||
|
val wavesPreview =
|
||||||
|
normalizedWaves.joinToString(",") {
|
||||||
|
String.format(Locale.US, "%.3f", it)
|
||||||
|
}
|
||||||
|
val preview = "$normalizedDuration::$wavesPreview"
|
||||||
|
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val attachmentId = "voice_$timestamp"
|
||||||
|
|
||||||
|
// 1. 🚀 Optimistic UI
|
||||||
|
val optimisticMessage =
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = "",
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = preview,
|
||||||
|
blob = normalizedVoiceHex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
addMessageSafely(optimisticMessage)
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val encryptionContext =
|
||||||
|
buildEncryptionContext(
|
||||||
|
plaintext = "",
|
||||||
|
recipient = recipient,
|
||||||
|
privateKey = privateKey
|
||||||
|
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||||
|
val encryptedContent = encryptionContext.encryptedContent
|
||||||
|
val encryptedKey = encryptionContext.encryptedKey
|
||||||
|
val aesChachaKey = encryptionContext.aesChachaKey
|
||||||
|
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val encryptedVoiceBlob =
|
||||||
|
encryptAttachmentPayload(normalizedVoiceHex, encryptionContext)
|
||||||
|
|
||||||
|
val isSavedMessages = (sender == recipient)
|
||||||
|
var uploadTag = ""
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob)
|
||||||
|
}
|
||||||
|
val attachmentTransportServer =
|
||||||
|
if (uploadTag.isNotEmpty()) {
|
||||||
|
TransportManager.getTransportServer().orEmpty()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceAttachment =
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = preview,
|
||||||
|
transportTag = uploadTag,
|
||||||
|
transportServer = attachmentTransportServer
|
||||||
|
)
|
||||||
|
|
||||||
|
val packet =
|
||||||
|
PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipient
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.aesChachaKey = aesChachaKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = listOf(voiceAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для отправителя сохраняем voice blob локально в encrypted cache.
|
||||||
|
runCatching {
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = getApplication(),
|
||||||
|
blob = normalizedVoiceHex,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.VOICE.value)
|
||||||
|
put("preview", preview)
|
||||||
|
put("blob", "")
|
||||||
|
put("transportTag", uploadTag)
|
||||||
|
put("transportServer", attachmentTransportServer)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
saveMessageToDatabase(
|
||||||
|
messageId = messageId,
|
||||||
|
text = "",
|
||||||
|
encryptedContent = encryptedContent,
|
||||||
|
encryptedKey =
|
||||||
|
if (encryptionContext.isGroup) {
|
||||||
|
buildStoredGroupKey(
|
||||||
|
encryptionContext.attachmentPassword,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
encryptedKey
|
||||||
|
},
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
|
attachmentsJson = attachmentsJson
|
||||||
|
)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDialog("Voice message", timestamp)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
saveDialog("Voice message", timestamp)
|
||||||
|
} finally {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
|
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
|
||||||
*/
|
*/
|
||||||
@@ -5280,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
|
||||||
|
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
|
||||||
|
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val privateKey =
|
val privateKey =
|
||||||
myPrivateKey
|
myPrivateKey
|
||||||
?: run {
|
?: run {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
@@ -62,6 +66,7 @@ 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.MessageRepository
|
||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
|
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.CallPhase
|
import com.rosetta.messenger.network.CallPhase
|
||||||
@@ -74,6 +79,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
|||||||
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||||
|
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
|
||||||
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
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
@@ -222,6 +228,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
|
|||||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean {
|
||||||
|
val active = playingDialogKey?.trim().orEmpty()
|
||||||
|
if (active.isEmpty()) return false
|
||||||
|
if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) {
|
||||||
|
return normalizeGroupDialogKey(dialogKey).equals(
|
||||||
|
normalizeGroupDialogKey(active),
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return dialogKey.trim().equals(active, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun shortPublicKey(value: String): String {
|
private fun shortPublicKey(value: String): String {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
if (trimmed.length <= 12) return trimmed
|
if (trimmed.length <= 12) return trimmed
|
||||||
@@ -237,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
|
|||||||
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
|
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun rosettaDev1Log(context: Context, tag: String, message: String) {
|
||||||
|
runCatching {
|
||||||
|
val dir = java.io.File(context.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
|
java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||||
@@ -297,9 +324,6 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
val view = androidx.compose.ui.platform.LocalView.current
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val hasNativeNavigationBar = remember(context) {
|
|
||||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
|
||||||
}
|
|
||||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -311,6 +335,21 @@ fun ChatsListScreen(
|
|||||||
var themeRevealToDark by remember { mutableStateOf(false) }
|
var themeRevealToDark by remember { mutableStateOf(false) }
|
||||||
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
||||||
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
|
// Prewarm: capture bitmap on first appear + when drawer opens
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
if (prewarmedBitmap == null) {
|
||||||
|
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(drawerState.isOpen) {
|
||||||
|
if (drawerState.isOpen) {
|
||||||
|
kotlinx.coroutines.delay(200)
|
||||||
|
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startThemeReveal() {
|
fun startThemeReveal() {
|
||||||
if (themeRevealActive) {
|
if (themeRevealActive) {
|
||||||
@@ -324,7 +363,10 @@ fun ChatsListScreen(
|
|||||||
val center =
|
val center =
|
||||||
themeToggleCenterInRoot
|
themeToggleCenterInRoot
|
||||||
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f)
|
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f)
|
||||||
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
|
||||||
|
// Use prewarmed bitmap or capture fresh
|
||||||
|
val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
prewarmedBitmap = null
|
||||||
if (snapshotBitmap == null) {
|
if (snapshotBitmap == null) {
|
||||||
onToggleTheme()
|
onToggleTheme()
|
||||||
return
|
return
|
||||||
@@ -333,6 +375,7 @@ fun ChatsListScreen(
|
|||||||
val toDark = !isDarkTheme
|
val toDark = !isDarkTheme
|
||||||
val maxRadius = maxRevealRadius(center, rootSize)
|
val maxRadius = maxRevealRadius(center, rootSize)
|
||||||
if (maxRadius <= 0f) {
|
if (maxRadius <= 0f) {
|
||||||
|
snapshotBitmap.recycle()
|
||||||
onToggleTheme()
|
onToggleTheme()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -448,23 +491,44 @@ fun ChatsListScreen(
|
|||||||
// <20>🔥 Пользователи, которые сейчас печатают
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||||
|
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||||
|
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
|
||||||
|
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||||
|
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||||
|
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||||
|
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||||
|
|
||||||
// Load dialogs when account is available
|
// Load dialogs as soon as public key is available.
|
||||||
|
// Private key may appear a bit later right after fresh registration on some devices.
|
||||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||||
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
val normalizedPublicKey = accountPublicKey.trim()
|
||||||
val launchStart = System.currentTimeMillis()
|
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
|
||||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
|
||||||
// Устанавливаем аккаунт для RecentSearchesManager
|
|
||||||
RecentSearchesManager.setAccount(accountPublicKey)
|
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
val normalizedPrivateKey = accountPrivateKey.trim()
|
||||||
// сообщений
|
val launchStart = System.currentTimeMillis()
|
||||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
|
||||||
android.util.Log.d(
|
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||||
"ChatsListScreen",
|
// Устанавливаем аккаунт для RecentSearchesManager
|
||||||
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
|
RecentSearchesManager.setAccount(normalizedPublicKey)
|
||||||
)
|
|
||||||
|
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||||
|
// сообщений только когда приватный ключ уже доступен.
|
||||||
|
if (normalizedPrivateKey.isNotEmpty()) {
|
||||||
|
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"ChatsListScreen",
|
||||||
|
"✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " +
|
||||||
|
"in ${System.currentTimeMillis() - launchStart}ms"
|
||||||
|
)
|
||||||
|
rosettaDev1Log(
|
||||||
|
context = context,
|
||||||
|
tag = "ChatsListScreen",
|
||||||
|
message =
|
||||||
|
"Account init effect pub=${shortPublicKey(normalizedPublicKey)} " +
|
||||||
|
"privReady=${normalizedPrivateKey.isNotEmpty()}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status dialog state
|
// Status dialog state
|
||||||
@@ -562,9 +626,44 @@ fun ChatsListScreen(
|
|||||||
LaunchedEffect(accountPublicKey) {
|
LaunchedEffect(accountPublicKey) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
val accounts = accountManager.getAllAccounts()
|
val accounts = accountManager.getAllAccounts()
|
||||||
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
val preferredPublicKey =
|
||||||
|
accountPublicKey.trim().ifBlank {
|
||||||
|
accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
|
}
|
||||||
|
allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val effectiveCurrentPublicKey =
|
||||||
|
remember(accountPublicKey, allAccounts) {
|
||||||
|
accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() }
|
||||||
|
}
|
||||||
|
val currentSidebarAccount =
|
||||||
|
remember(allAccounts, effectiveCurrentPublicKey) {
|
||||||
|
allAccounts.firstOrNull {
|
||||||
|
it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true)
|
||||||
|
} ?: allAccounts.firstOrNull()
|
||||||
|
}
|
||||||
|
val sidebarAccountUsername =
|
||||||
|
remember(accountUsername, currentSidebarAccount) {
|
||||||
|
accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() }
|
||||||
|
}
|
||||||
|
val sidebarAccountName =
|
||||||
|
remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) {
|
||||||
|
val preferredName =
|
||||||
|
when {
|
||||||
|
accountName.isNotBlank() &&
|
||||||
|
!isPlaceholderAccountName(accountName) -> accountName
|
||||||
|
!currentSidebarAccount?.name.isNullOrBlank() ->
|
||||||
|
currentSidebarAccount?.name.orEmpty()
|
||||||
|
else -> accountName
|
||||||
|
}
|
||||||
|
resolveAccountDisplayName(
|
||||||
|
effectiveCurrentPublicKey,
|
||||||
|
preferredName,
|
||||||
|
sidebarAccountUsername
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Confirmation dialogs state
|
// Confirmation dialogs state
|
||||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
@@ -583,7 +682,7 @@ fun ChatsListScreen(
|
|||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
var showSelectionMenu by remember { mutableStateOf(false) }
|
var showSelectionMenu by remember { mutableStateOf(false) }
|
||||||
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
||||||
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
|
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
|
||||||
.collectAsState(initial = emptySet())
|
.collectAsState(initial = emptySet())
|
||||||
|
|
||||||
// Back: drawer → закрыть, selection → сбросить
|
// Back: drawer → закрыть, selection → сбросить
|
||||||
@@ -614,6 +713,31 @@ fun ChatsListScreen(
|
|||||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||||
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
|
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
|
||||||
|
|
||||||
|
// Anti-stuck guard:
|
||||||
|
// если соединение уже AUTHENTICATED и синхронизация завершена,
|
||||||
|
// loading не должен висеть бесконечно.
|
||||||
|
LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) {
|
||||||
|
val normalizedPublicKey = accountPublicKey.trim()
|
||||||
|
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
|
||||||
|
if (!topLevelIsLoading) return@LaunchedEffect
|
||||||
|
if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect
|
||||||
|
|
||||||
|
delay(1200)
|
||||||
|
if (
|
||||||
|
topLevelIsLoading &&
|
||||||
|
protocolState == ProtocolState.AUTHENTICATED &&
|
||||||
|
!syncInProgress
|
||||||
|
) {
|
||||||
|
rosettaDev1Log(
|
||||||
|
context = context,
|
||||||
|
tag = "ChatsListScreen",
|
||||||
|
message =
|
||||||
|
"loading guard fired pub=${shortPublicKey(normalizedPublicKey)}"
|
||||||
|
)
|
||||||
|
chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dev console dialog - commented out for now
|
// Dev console dialog - commented out for now
|
||||||
/*
|
/*
|
||||||
if (showDevConsole) {
|
if (showDevConsole) {
|
||||||
@@ -764,10 +888,6 @@ fun ChatsListScreen(
|
|||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.onSizeChanged { rootSize = it }
|
.onSizeChanged { rootSize = it }
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.then(
|
|
||||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
|
||||||
else Modifier
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -850,16 +970,16 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
AvatarImage(
|
AvatarImage(
|
||||||
publicKey =
|
publicKey =
|
||||||
accountPublicKey,
|
effectiveCurrentPublicKey,
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
size = 72.dp,
|
size = 72.dp,
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
displayName =
|
displayName =
|
||||||
accountName
|
sidebarAccountName
|
||||||
.ifEmpty {
|
.ifEmpty {
|
||||||
accountUsername
|
sidebarAccountUsername
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -911,13 +1031,13 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
// Display name
|
// Display name
|
||||||
if (accountName.isNotEmpty()) {
|
if (sidebarAccountName.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = accountName,
|
text = sidebarAccountName,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White
|
color = Color.White
|
||||||
@@ -936,10 +1056,10 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Username
|
// Username
|
||||||
if (accountUsername.isNotEmpty()) {
|
if (sidebarAccountUsername.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "@$accountUsername",
|
text = "@$sidebarAccountUsername",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = Color.White.copy(alpha = 0.7f)
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
@@ -980,7 +1100,9 @@ fun ChatsListScreen(
|
|||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
// All accounts list (max 5 like Telegram sidebar behavior)
|
// All accounts list (max 5 like Telegram sidebar behavior)
|
||||||
allAccounts.take(5).forEach { account ->
|
allAccounts.take(5).forEach { account ->
|
||||||
val isCurrentAccount = account.publicKey == accountPublicKey
|
val isCurrentAccount =
|
||||||
|
account.publicKey ==
|
||||||
|
effectiveCurrentPublicKey
|
||||||
val displayName =
|
val displayName =
|
||||||
resolveAccountDisplayName(
|
resolveAccountDisplayName(
|
||||||
account.publicKey,
|
account.publicKey,
|
||||||
@@ -1260,6 +1382,9 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Keep distance from footer divider so it never overlays Settings.
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -1268,9 +1393,12 @@ fun ChatsListScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.then(
|
.windowInsetsPadding(
|
||||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
WindowInsets
|
||||||
else Modifier
|
.navigationBars
|
||||||
|
.only(
|
||||||
|
WindowInsetsSides.Bottom
|
||||||
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Telegram-style update banner
|
// Telegram-style update banner
|
||||||
@@ -2111,6 +2239,50 @@ fun ChatsListScreen(
|
|||||||
callUiState.phase != CallPhase.INCOMING
|
callUiState.phase != CallPhase.INCOMING
|
||||||
}
|
}
|
||||||
val callBannerHeight = 40.dp
|
val callBannerHeight = 40.dp
|
||||||
|
val showVoiceMiniPlayer =
|
||||||
|
remember(
|
||||||
|
showRequestsScreen,
|
||||||
|
showDownloadsScreen,
|
||||||
|
showCallsScreen,
|
||||||
|
playingVoiceAttachmentId
|
||||||
|
) {
|
||||||
|
!showRequestsScreen &&
|
||||||
|
!showDownloadsScreen &&
|
||||||
|
!showCallsScreen &&
|
||||||
|
!playingVoiceAttachmentId.isNullOrBlank()
|
||||||
|
}
|
||||||
|
val voiceBannerHeight = 36.dp
|
||||||
|
val stickyTopInset =
|
||||||
|
remember(
|
||||||
|
showStickyCallBanner,
|
||||||
|
showVoiceMiniPlayer
|
||||||
|
) {
|
||||||
|
var topInset = 0.dp
|
||||||
|
if (showStickyCallBanner) {
|
||||||
|
topInset += callBannerHeight
|
||||||
|
}
|
||||||
|
if (showVoiceMiniPlayer) {
|
||||||
|
topInset += voiceBannerHeight
|
||||||
|
}
|
||||||
|
topInset
|
||||||
|
}
|
||||||
|
val voiceMiniPlayerTitle =
|
||||||
|
remember(
|
||||||
|
playingVoiceSenderLabel,
|
||||||
|
playingVoiceTimeLabel
|
||||||
|
) {
|
||||||
|
val sender =
|
||||||
|
playingVoiceSenderLabel
|
||||||
|
.trim()
|
||||||
|
.ifBlank {
|
||||||
|
"Voice"
|
||||||
|
}
|
||||||
|
val time =
|
||||||
|
playingVoiceTimeLabel
|
||||||
|
.trim()
|
||||||
|
if (time.isBlank()) sender
|
||||||
|
else "$sender at $time"
|
||||||
|
}
|
||||||
// 🔥 Берем dialogs из chatsState для
|
// 🔥 Берем dialogs из chatsState для
|
||||||
// консистентности
|
// консистентности
|
||||||
// 📌 Порядок по времени готовится в ViewModel.
|
// 📌 Порядок по времени готовится в ViewModel.
|
||||||
@@ -2313,9 +2485,7 @@ fun ChatsListScreen(
|
|||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.padding(
|
.padding(
|
||||||
top =
|
top =
|
||||||
if (showStickyCallBanner)
|
stickyTopInset
|
||||||
callBannerHeight
|
|
||||||
else 0.dp
|
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
||||||
@@ -2553,6 +2723,18 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isVoicePlaybackActive by
|
||||||
|
remember(
|
||||||
|
dialog.opponentKey,
|
||||||
|
playingVoiceDialogKey
|
||||||
|
) {
|
||||||
|
derivedStateOf {
|
||||||
|
isVoicePlayingForDialog(
|
||||||
|
dialog.opponentKey,
|
||||||
|
playingVoiceDialogKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val isSelectedDialog =
|
val isSelectedDialog =
|
||||||
selectedChatKeys
|
selectedChatKeys
|
||||||
.contains(
|
.contains(
|
||||||
@@ -2594,6 +2776,8 @@ fun ChatsListScreen(
|
|||||||
typingDisplayName,
|
typingDisplayName,
|
||||||
typingSenderPublicKey =
|
typingSenderPublicKey =
|
||||||
typingSenderPublicKey,
|
typingSenderPublicKey,
|
||||||
|
isVoicePlaybackActive =
|
||||||
|
isVoicePlaybackActive,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
@@ -2727,14 +2911,51 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showStickyCallBanner) {
|
if (showStickyCallBanner || showVoiceMiniPlayer) {
|
||||||
CallTopBanner(
|
Column(
|
||||||
state = callUiState,
|
modifier =
|
||||||
isSticky = true,
|
Modifier.fillMaxWidth()
|
||||||
isDarkTheme = isDarkTheme,
|
.align(
|
||||||
avatarRepository = avatarRepository,
|
Alignment.TopCenter
|
||||||
onOpenCall = onOpenCallOverlay
|
)
|
||||||
)
|
) {
|
||||||
|
if (showStickyCallBanner) {
|
||||||
|
CallTopBanner(
|
||||||
|
state = callUiState,
|
||||||
|
isSticky = true,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
onOpenCall = onOpenCallOverlay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showVoiceMiniPlayer,
|
||||||
|
enter = expandVertically(
|
||||||
|
animationSpec = tween(220, easing = FastOutSlowInEasing),
|
||||||
|
expandFrom = Alignment.Top
|
||||||
|
) + fadeIn(animationSpec = tween(220)),
|
||||||
|
exit = shrinkVertically(
|
||||||
|
animationSpec = tween(260, easing = FastOutSlowInEasing),
|
||||||
|
shrinkTowards = Alignment.Top
|
||||||
|
) + fadeOut(animationSpec = tween(180))
|
||||||
|
) {
|
||||||
|
VoiceTopMiniPlayer(
|
||||||
|
title = voiceMiniPlayerTitle,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
isPlaying = isVoicePlaybackRunning,
|
||||||
|
speed = voicePlaybackSpeed,
|
||||||
|
onTogglePlay = {
|
||||||
|
VoicePlaybackCoordinator.toggleCurrentPlayback()
|
||||||
|
},
|
||||||
|
onCycleSpeed = {
|
||||||
|
VoicePlaybackCoordinator.cycleSpeed()
|
||||||
|
},
|
||||||
|
onClose = {
|
||||||
|
VoicePlaybackCoordinator.stop()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3703,6 +3924,7 @@ fun SwipeableDialogItem(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = "",
|
typingSenderPublicKey: String = "",
|
||||||
|
isVoicePlaybackActive: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isGroupChat: Boolean = false,
|
isGroupChat: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
@@ -4106,6 +4328,7 @@ fun SwipeableDialogItem(
|
|||||||
isTyping = isTyping,
|
isTyping = isTyping,
|
||||||
typingDisplayName = typingDisplayName,
|
typingDisplayName = typingDisplayName,
|
||||||
typingSenderPublicKey = typingSenderPublicKey,
|
typingSenderPublicKey = typingSenderPublicKey,
|
||||||
|
isVoicePlaybackActive = isVoicePlaybackActive,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
@@ -4125,6 +4348,7 @@ fun DialogItemContent(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = "",
|
typingSenderPublicKey: String = "",
|
||||||
|
isVoicePlaybackActive: Boolean = false,
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
@@ -4259,12 +4483,12 @@ fun DialogItemContent(
|
|||||||
// Name and last message
|
// Name and last message
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
@@ -4274,7 +4498,8 @@ fun DialogItemContent(
|
|||||||
color = textColor,
|
color = textColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
enableLinks = false
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
)
|
)
|
||||||
if (isGroupDialog) {
|
if (isGroupDialog) {
|
||||||
Spacer(modifier = Modifier.width(5.dp))
|
Spacer(modifier = Modifier.width(5.dp))
|
||||||
@@ -4282,7 +4507,7 @@ fun DialogItemContent(
|
|||||||
imageVector = TablerIcons.Users,
|
imageVector = TablerIcons.Users,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = secondaryTextColor.copy(alpha = 0.9f),
|
tint = secondaryTextColor.copy(alpha = 0.9f),
|
||||||
modifier = Modifier.size(15.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||||
@@ -4291,7 +4516,7 @@ fun DialogItemContent(
|
|||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||||
size = 16,
|
size = 16,
|
||||||
modifier = Modifier.offset(y = (-2).dp),
|
modifier = Modifier,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
badgeTint = PrimaryBlue
|
badgeTint = PrimaryBlue
|
||||||
)
|
)
|
||||||
@@ -4318,6 +4543,7 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.heightIn(min = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
@@ -4448,7 +4674,7 @@ fun DialogItemContent(
|
|||||||
0.6f
|
0.6f
|
||||||
),
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(14.dp)
|
Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -4468,9 +4694,11 @@ fun DialogItemContent(
|
|||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (visibleUnreadCount > 0) PrimaryBlue
|
if (visibleUnreadCount > 0) PrimaryBlue
|
||||||
else secondaryTextColor
|
else secondaryTextColor,
|
||||||
|
modifier = Modifier.align(Alignment.CenterVertically)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4487,18 +4715,35 @@ fun DialogItemContent(
|
|||||||
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
|
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
|
val subtitleMode =
|
||||||
|
remember(
|
||||||
|
isVoicePlaybackActive,
|
||||||
|
isTyping,
|
||||||
|
dialog.draftText
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
isVoicePlaybackActive -> "voice"
|
||||||
|
isTyping -> "typing"
|
||||||
|
!dialog.draftText.isNullOrEmpty() -> "draft"
|
||||||
|
else -> "message"
|
||||||
|
}
|
||||||
|
}
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = isTyping,
|
targetState = subtitleMode,
|
||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "chatSubtitle"
|
label = "chatSubtitle"
|
||||||
) { showTyping ->
|
) { mode ->
|
||||||
if (showTyping) {
|
if (mode == "voice") {
|
||||||
|
VoicePlaybackIndicatorSmall(
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
} else if (mode == "typing") {
|
||||||
TypingIndicatorSmall(
|
TypingIndicatorSmall(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
typingDisplayName = typingDisplayName,
|
typingDisplayName = typingDisplayName,
|
||||||
typingSenderPublicKey = typingSenderPublicKey
|
typingSenderPublicKey = typingSenderPublicKey
|
||||||
)
|
)
|
||||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
} else if (mode == "draft") {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "Draft: ",
|
text = "Draft: ",
|
||||||
@@ -4508,7 +4753,7 @@ fun DialogItemContent(
|
|||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = dialog.draftText,
|
text = dialog.draftText.orEmpty(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = secondaryTextColor,
|
color = secondaryTextColor,
|
||||||
@@ -4530,6 +4775,8 @@ fun DialogItemContent(
|
|||||||
"Avatar" -> "Avatar"
|
"Avatar" -> "Avatar"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Call" -> "Call"
|
"Call" -> "Call"
|
||||||
|
dialog.lastMessageAttachmentType ==
|
||||||
|
"Voice message" -> "Voice message"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Forwarded" ->
|
"Forwarded" ->
|
||||||
"Forwarded message"
|
"Forwarded message"
|
||||||
@@ -4847,6 +5094,167 @@ fun TypingIndicatorSmall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoicePlaybackIndicatorSmall(
|
||||||
|
isDarkTheme: Boolean
|
||||||
|
) {
|
||||||
|
val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
|
||||||
|
val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
|
||||||
|
val levels = List(3) { index ->
|
||||||
|
transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = keyframes {
|
||||||
|
durationMillis = 900
|
||||||
|
0f at 0
|
||||||
|
1f at 280
|
||||||
|
0.2f at 580
|
||||||
|
0f at 900
|
||||||
|
},
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
initialStartOffset = StartOffset(index * 130)
|
||||||
|
),
|
||||||
|
label = "voiceBar$index"
|
||||||
|
).value
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.heightIn(min = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
|
||||||
|
val barWidth = 2.dp.toPx()
|
||||||
|
val gap = 2.dp.toPx()
|
||||||
|
val baseY = size.height
|
||||||
|
repeat(3) { index ->
|
||||||
|
val x = index * (barWidth + gap)
|
||||||
|
val progress = levels[index].coerceIn(0f, 1f)
|
||||||
|
val minH = 3.dp.toPx()
|
||||||
|
val maxH = 10.dp.toPx()
|
||||||
|
val height = minH + (maxH - minH) * progress
|
||||||
|
drawRoundRect(
|
||||||
|
color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
|
||||||
|
topLeft = Offset(x, baseY - height),
|
||||||
|
size = androidx.compose.ui.geometry.Size(barWidth, height),
|
||||||
|
cornerRadius =
|
||||||
|
androidx.compose.ui.geometry.CornerRadius(
|
||||||
|
x = barWidth,
|
||||||
|
y = barWidth
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(5.dp))
|
||||||
|
AppleEmojiText(
|
||||||
|
text = "Listening",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = accentColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatVoiceSpeedLabel(speed: Float): String {
|
||||||
|
val normalized = (speed * 10f).roundToInt() / 10f
|
||||||
|
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
|
||||||
|
"${normalized.toInt()}x"
|
||||||
|
} else {
|
||||||
|
"${normalized}x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VoiceTopMiniPlayer(
|
||||||
|
title: String,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
isPlaying: Boolean,
|
||||||
|
speed: Float,
|
||||||
|
onTogglePlay: () -> Unit,
|
||||||
|
onCycleSpeed: () -> Unit,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
// Match overall screen surface aesthetic — neutral elevated surface, no blue accent
|
||||||
|
val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||||
|
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
|
||||||
|
val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||||
|
val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onTogglePlay,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isPlaying) Icons.Default.Pause
|
||||||
|
else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
|
||||||
|
tint = primaryIconColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
AppleEmojiText(
|
||||||
|
text = title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onCycleSpeed() }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatVoiceSpeedLabel(speed),
|
||||||
|
color = secondaryColor,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close voice",
|
||||||
|
tint = secondaryColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwipeBackContainer(
|
private fun SwipeBackContainer(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -5446,7 +5854,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -5506,7 +5914,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -5540,7 +5948,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
fun DrawerDivider(isDarkTheme: Boolean) {
|
fun DrawerDivider(isDarkTheme: Boolean) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Divider(
|
Divider(
|
||||||
modifier = Modifier.padding(horizontal = 20.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
||||||
thickness = 0.5.dp
|
thickness = 0.5.dp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
|
|||||||
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.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
// Job для отмены подписок при смене аккаунта
|
// Job для отмены подписок при смене аккаунта
|
||||||
private var accountSubscriptionsJob: Job? = null
|
private var accountSubscriptionsJob: Job? = null
|
||||||
|
private var loadingFailSafeJob: Job? = null
|
||||||
|
|
||||||
// Список диалогов с расшифрованными сообщениями
|
// Список диалогов с расшифрованными сообщениями
|
||||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||||
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
ChatsUiState()
|
ChatsUiState()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty)
|
// Загрузка
|
||||||
private val _isLoading = MutableStateFlow(true)
|
// Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
private val loadingFailSafeTimeoutMs = 4500L
|
||||||
|
|
||||||
private val TAG = "ChatsListVM"
|
private val TAG = "ChatsListVM"
|
||||||
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
|
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
|
||||||
@@ -146,6 +152,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun rosettaDev1Log(msg: String) {
|
||||||
|
runCatching {
|
||||||
|
val app = getApplication<Application>()
|
||||||
|
val dir = java.io.File(app.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
|
java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class GroupLastSenderInfo(
|
private data class GroupLastSenderInfo(
|
||||||
val senderPrefix: String,
|
val senderPrefix: String,
|
||||||
val senderKey: String
|
val senderKey: String
|
||||||
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
/** Установить текущий аккаунт и загрузить диалоги */
|
/** Установить текущий аккаунт и загрузить диалоги */
|
||||||
fun setAccount(publicKey: String, privateKey: String) {
|
fun setAccount(publicKey: String, privateKey: String) {
|
||||||
val setAccountStart = System.currentTimeMillis()
|
val resolvedPrivateKey =
|
||||||
if (currentAccount == publicKey) {
|
when {
|
||||||
|
privateKey.isNotBlank() -> privateKey
|
||||||
|
currentAccount == publicKey -> currentPrivateKey.orEmpty()
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
|
||||||
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
|
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
|
||||||
if (_isLoading.value) _isLoading.value = false
|
if (_isLoading.value) {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
loadingFailSafeJob?.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Показываем skeleton пока данные грузятся
|
// 🔥 Показываем skeleton пока данные грузятся
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
|
loadingFailSafeJob?.cancel()
|
||||||
|
loadingFailSafeJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(loadingFailSafeTimeoutMs)
|
||||||
|
if (_isLoading.value) {
|
||||||
|
_isLoading.value = false
|
||||||
|
android.util.Log.w(
|
||||||
|
TAG,
|
||||||
|
"Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
|
||||||
|
)
|
||||||
|
rosettaDev1Log(
|
||||||
|
"Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
requestedUserInfoKeys.clear()
|
requestedUserInfoKeys.clear()
|
||||||
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
accountSubscriptionsJob?.cancel()
|
accountSubscriptionsJob?.cancel()
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = resolvedPrivateKey
|
||||||
|
|
||||||
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
|
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
|
||||||
DraftManager.setAccount(publicKey)
|
DraftManager.setAccount(publicKey)
|
||||||
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
_requestsCount.value = 0
|
_requestsCount.value = 0
|
||||||
|
|
||||||
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
if (resolvedPrivateKey.isNotEmpty()) {
|
||||||
|
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
|
||||||
|
}
|
||||||
|
|
||||||
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
||||||
accountSubscriptionsJob = viewModelScope.launch {
|
accountSubscriptionsJob = viewModelScope.launch {
|
||||||
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
} else {
|
} else {
|
||||||
mapDialogListIncremental(
|
mapDialogListIncremental(
|
||||||
dialogsList = dialogsList,
|
dialogsList = dialogsList,
|
||||||
privateKey = privateKey,
|
privateKey = resolvedPrivateKey,
|
||||||
cache = dialogsUiCache,
|
cache = dialogsUiCache,
|
||||||
isRequestsFlow = false
|
isRequestsFlow = false
|
||||||
)
|
)
|
||||||
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
|
.catch { e ->
|
||||||
|
android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
|
||||||
|
rosettaDev1Log("Dialogs flow failed: ${e.message}")
|
||||||
|
if (_isLoading.value) _isLoading.value = false
|
||||||
|
emit(emptyList())
|
||||||
|
}
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
// 🚀 Убираем skeleton после первой загрузки
|
// 🚀 Убираем skeleton после первой загрузки
|
||||||
if (_isLoading.value) _isLoading.value = false
|
if (_isLoading.value) {
|
||||||
|
_isLoading.value = false
|
||||||
|
loadingFailSafeJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
||||||
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
decryptedDialogs.filter { !it.isSavedMessages }.map {
|
decryptedDialogs.filter { !it.isSavedMessages }.map {
|
||||||
it.opponentKey
|
it.opponentKey
|
||||||
}
|
}
|
||||||
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
|
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
} else {
|
} else {
|
||||||
mapDialogListIncremental(
|
mapDialogListIncremental(
|
||||||
dialogsList = requestsList,
|
dialogsList = requestsList,
|
||||||
privateKey = privateKey,
|
privateKey = resolvedPrivateKey,
|
||||||
cache = requestsUiCache,
|
cache = requestsUiCache,
|
||||||
isRequestsFlow = true
|
isRequestsFlow = true
|
||||||
)
|
)
|
||||||
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
} // end accountSubscriptionsJob
|
} // end accountSubscriptionsJob
|
||||||
|
|
||||||
|
accountSubscriptionsJob?.invokeOnCompletion { cause ->
|
||||||
|
if (cause != null && _isLoading.value) {
|
||||||
|
_isLoading.value = false
|
||||||
|
loadingFailSafeJob?.cancel()
|
||||||
|
android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
|
||||||
|
rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceStopLoading(reason: String) {
|
||||||
|
if (_isLoading.value) {
|
||||||
|
_isLoading.value = false
|
||||||
|
loadingFailSafeJob?.cancel()
|
||||||
|
android.util.Log.w(TAG, "forceStopLoading: $reason")
|
||||||
|
rosettaDev1Log("forceStopLoading: $reason")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,6 +575,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
*/
|
*/
|
||||||
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||||
if (opponentKeys.isEmpty()) return
|
if (opponentKeys.isEmpty()) return
|
||||||
|
if (privateKey.isBlank()) return
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||||
val newKeys =
|
val newKeys =
|
||||||
@@ -573,16 +643,52 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
lastMessageAttachments: String
|
lastMessageAttachments: String
|
||||||
): String? {
|
): String? {
|
||||||
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||||
return when (attachmentType) {
|
val effectiveType =
|
||||||
|
if (attachmentType >= 0) attachmentType
|
||||||
|
else inferAttachmentTypeFromJson(lastMessageAttachments)
|
||||||
|
|
||||||
|
return when (effectiveType) {
|
||||||
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||||
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
||||||
2 -> "File" // AttachmentType.FILE = 2
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
4 -> "Call" // AttachmentType.CALL = 4
|
4 -> "Call" // AttachmentType.CALL = 4
|
||||||
|
5 -> "Voice message" // AttachmentType.VOICE = 5
|
||||||
|
6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
|
||||||
else -> if (inferredCall) "Call" else null
|
else -> if (inferredCall) "Call" else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
|
||||||
|
if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
|
||||||
|
return try {
|
||||||
|
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
|
||||||
|
if (attachments.length() <= 0) return -1
|
||||||
|
val first = attachments.optJSONObject(0) ?: return -1
|
||||||
|
val rawType = first.opt("type")
|
||||||
|
when (rawType) {
|
||||||
|
is Number -> rawType.toInt()
|
||||||
|
is String -> {
|
||||||
|
val normalized = rawType.trim()
|
||||||
|
normalized.toIntOrNull()
|
||||||
|
?: when (normalized.lowercase(Locale.ROOT)) {
|
||||||
|
"image" -> 0
|
||||||
|
"messages", "reply", "forward" -> 1
|
||||||
|
"file" -> 2
|
||||||
|
"avatar" -> 3
|
||||||
|
"call" -> 4
|
||||||
|
"voice" -> 5
|
||||||
|
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
|
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
|
||||||
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
|
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupDescription.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
AppleEmojiText(
|
|
||||||
text = groupDescription,
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
|
||||||
enableLinks = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
if (groupDescription.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = sectionColor
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
AppleEmojiText(
|
||||||
|
text = groupDescription,
|
||||||
|
color = primaryText,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 8,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
enableLinks = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Description",
|
||||||
|
color = Color(0xFF8E8E93),
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider(
|
||||||
|
color = borderColor,
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
color = sectionColor
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
color = sectionColor,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Add Members
|
// Add Members — flat Telegram style, edge-to-edge, white text
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
|
|||||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(28.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Add Members",
|
text = "Add Members",
|
||||||
color = primaryText,
|
color = primaryText,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PersonAdd,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(groupMenuTrailingIconSize)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider(
|
Divider(
|
||||||
color = borderColor,
|
color = borderColor,
|
||||||
thickness = 0.5.dp,
|
thickness = 0.5.dp,
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Encryption Key
|
// Encryption Key — flat Telegram style, edge-to-edge, white text
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
|
|||||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(28.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Encryption Key",
|
text = "Encryption Key",
|
||||||
color = primaryText,
|
color = primaryText,
|
||||||
|
|||||||
@@ -573,23 +573,23 @@ private fun ChatsTabContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Recent header (always show with Clear All) ───
|
// ─── Recent header (only when there are recents) ───
|
||||||
item {
|
if (recentUsers.isNotEmpty()) {
|
||||||
Row(
|
item {
|
||||||
modifier = Modifier
|
Row(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxWidth()
|
||||||
.padding(top = 14.dp, bottom = 6.dp),
|
.padding(horizontal = 16.dp)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
.padding(top = 14.dp, bottom = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Text(
|
) {
|
||||||
"Recent",
|
Text(
|
||||||
fontSize = 15.sp,
|
"Recent",
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontSize = 15.sp,
|
||||||
color = PrimaryBlue
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
color = PrimaryBlue
|
||||||
if (recentUsers.isNotEmpty()) {
|
)
|
||||||
Text(
|
Text(
|
||||||
"Clear All",
|
"Clear All",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.chats.calls
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
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
|
||||||
@@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.CallMade
|
import androidx.compose.material.icons.filled.CallMade
|
||||||
@@ -34,10 +33,16 @@ import androidx.compose.ui.Alignment
|
|||||||
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 androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.airbnb.lottie.compose.LottieAnimation
|
||||||
|
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||||
|
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||||
|
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||||
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.database.CallHistoryRow
|
import com.rosetta.messenger.database.CallHistoryRow
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
@@ -106,16 +111,21 @@ fun CallsHistoryScreen(
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize().background(backgroundColor),
|
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp)
|
contentPadding = if (items.isEmpty()) PaddingValues(0.dp) else PaddingValues(bottom = 16.dp)
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
item(key = "empty_calls") {
|
item(key = "empty_calls") {
|
||||||
EmptyCallsState(
|
Column(
|
||||||
isDarkTheme = isDarkTheme,
|
modifier = Modifier.fillParentMaxSize(),
|
||||||
title = "No calls yet",
|
verticalArrangement = Arrangement.Center
|
||||||
subtitle = "Your call history will appear here",
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
EmptyCallsState(
|
||||||
)
|
isDarkTheme = isDarkTheme,
|
||||||
|
title = "No Calls Yet",
|
||||||
|
subtitle = "Your recent voice and video calls will\nappear here.",
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(items, key = { it.messageId }) { item ->
|
items(items, key = { it.messageId }) { item ->
|
||||||
@@ -273,39 +283,63 @@ private fun EmptyCallsState(
|
|||||||
subtitle: String,
|
subtitle: String,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
|
val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
|
||||||
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
|
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
|
||||||
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
val cardColor = if (isDarkTheme) Color(0xFF242529) else Color(0xFFF6F6FA)
|
||||||
|
val lottieComposition by rememberLottieComposition(
|
||||||
|
LottieCompositionSpec.RawRes(R.raw.phone_duck)
|
||||||
|
)
|
||||||
|
val lottieProgress by animateLottieCompositionAsState(
|
||||||
|
composition = lottieComposition,
|
||||||
|
iterations = 1
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.padding(horizontal = 32.dp),
|
modifier = modifier,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.Center
|
.fillMaxWidth()
|
||||||
|
.background(cardColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (lottieComposition != null) {
|
||||||
imageVector = Icons.Default.Call,
|
LottieAnimation(
|
||||||
contentDescription = null,
|
composition = lottieComposition,
|
||||||
tint = iconTint,
|
progress = { lottieProgress },
|
||||||
modifier = Modifier.size(34.dp)
|
modifier = Modifier.size(184.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Call,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = subtitleColor,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = titleColor,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = subtitleColor,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
color = titleColor,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
color = subtitleColor,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -320,6 +320,7 @@ fun TypingIndicator(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(
|
fun MessageBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
|
textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
hasWallpaper: Boolean = false,
|
hasWallpaper: Boolean = false,
|
||||||
isSystemSafeChat: Boolean = false,
|
isSystemSafeChat: Boolean = false,
|
||||||
@@ -354,6 +355,16 @@ fun MessageBubble(
|
|||||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||||
contextMenuContent: @Composable () -> Unit = {}
|
contextMenuContent: @Composable () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val isTextSelectionOnThisMessage =
|
||||||
|
remember(
|
||||||
|
textSelectionHelper?.isInSelectionMode,
|
||||||
|
textSelectionHelper?.selectedMessageId,
|
||||||
|
message.id
|
||||||
|
) {
|
||||||
|
textSelectionHelper?.isInSelectionMode == true &&
|
||||||
|
textSelectionHelper.selectedMessageId == message.id
|
||||||
|
}
|
||||||
|
|
||||||
// Swipe-to-reply state
|
// Swipe-to-reply state
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
@@ -373,7 +384,7 @@ fun MessageBubble(
|
|||||||
// Selection animations
|
// Selection animations
|
||||||
val selectionAlpha by
|
val selectionAlpha by
|
||||||
animateFloatAsState(
|
animateFloatAsState(
|
||||||
targetValue = if (isSelected) 0.85f else 1f,
|
targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
|
||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "selectionAlpha"
|
label = "selectionAlpha"
|
||||||
)
|
)
|
||||||
@@ -400,6 +411,10 @@ fun MessageBubble(
|
|||||||
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
||||||
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
||||||
}
|
}
|
||||||
|
var textViewRef by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiTextView?>(null) }
|
||||||
|
val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) {
|
||||||
|
{ textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() }
|
||||||
|
} else null
|
||||||
val linksEnabled = !isSelectionMode
|
val linksEnabled = !isSelectionMode
|
||||||
val textClickHandler: (() -> Unit)? = onClick
|
val textClickHandler: (() -> Unit)? = onClick
|
||||||
val mentionClickHandler: ((String) -> Unit)? =
|
val mentionClickHandler: ((String) -> Unit)? =
|
||||||
@@ -475,8 +490,9 @@ fun MessageBubble(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
|
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
|
||||||
if (isSystemSafeChat) return@pointerInput
|
if (isSystemSafeChat) return@pointerInput
|
||||||
|
if (textSelectionHelper?.isActive == true) return@pointerInput
|
||||||
// 🔥 Простой горизонтальный свайп для reply
|
// 🔥 Простой горизонтальный свайп для reply
|
||||||
// Используем detectHorizontalDragGestures который лучше работает со
|
// Используем detectHorizontalDragGestures который лучше работает со
|
||||||
// скроллом
|
// скроллом
|
||||||
@@ -552,7 +568,8 @@ fun MessageBubble(
|
|||||||
val selectionBackgroundColor by
|
val selectionBackgroundColor by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
targetValue =
|
targetValue =
|
||||||
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
|
if (isSelected && !isTextSelectionOnThisMessage)
|
||||||
|
PrimaryBlue.copy(alpha = 0.15f)
|
||||||
else Color.Transparent,
|
else Color.Transparent,
|
||||||
animationSpec = tween(200),
|
animationSpec = tween(200),
|
||||||
label = "selectionBg"
|
label = "selectionBg"
|
||||||
@@ -684,7 +701,18 @@ fun MessageBubble(
|
|||||||
message.attachments.all {
|
message.attachments.all {
|
||||||
it.type ==
|
it.type ==
|
||||||
com.rosetta.messenger.network.AttachmentType
|
com.rosetta.messenger.network.AttachmentType
|
||||||
.IMAGE
|
.IMAGE ||
|
||||||
|
it.type ==
|
||||||
|
com.rosetta.messenger.network
|
||||||
|
.AttachmentType
|
||||||
|
.VIDEO_CIRCLE
|
||||||
|
}
|
||||||
|
val hasOnlyVideoCircle =
|
||||||
|
hasOnlyMedia &&
|
||||||
|
message.attachments.all {
|
||||||
|
it.type ==
|
||||||
|
com.rosetta.messenger.network.AttachmentType
|
||||||
|
.VIDEO_CIRCLE
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фото + caption (как в Telegram)
|
// Фото + caption (как в Telegram)
|
||||||
@@ -707,6 +735,8 @@ fun MessageBubble(
|
|||||||
message.attachments.all {
|
message.attachments.all {
|
||||||
it.type == AttachmentType.CALL
|
it.type == AttachmentType.CALL
|
||||||
}
|
}
|
||||||
|
val hasVoiceAttachment =
|
||||||
|
message.attachments.any { it.type == AttachmentType.VOICE }
|
||||||
|
|
||||||
val isStandaloneGroupInvite =
|
val isStandaloneGroupInvite =
|
||||||
message.attachments.isEmpty() &&
|
message.attachments.isEmpty() &&
|
||||||
@@ -725,7 +755,8 @@ fun MessageBubble(
|
|||||||
hasImageWithCaption -> PaddingValues(0.dp)
|
hasImageWithCaption -> PaddingValues(0.dp)
|
||||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
}
|
}
|
||||||
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
|
val bubbleBorderWidth =
|
||||||
|
if (hasOnlyMedia && !hasOnlyVideoCircle) 1.dp else 0.dp
|
||||||
|
|
||||||
// Telegram-style: ширина пузырька = ширина фото
|
// Telegram-style: ширина пузырька = ширина фото
|
||||||
// Caption переносится на новые строки, не расширяя пузырёк
|
// Caption переносится на новые строки, не расширяя пузырёк
|
||||||
@@ -743,7 +774,9 @@ fun MessageBubble(
|
|||||||
// Вычисляем ширину фото для ограничения пузырька
|
// Вычисляем ширину фото для ограничения пузырька
|
||||||
val photoWidth =
|
val photoWidth =
|
||||||
if (hasImageWithCaption || hasOnlyMedia) {
|
if (hasImageWithCaption || hasOnlyMedia) {
|
||||||
if (isImageCollage) {
|
if (hasOnlyVideoCircle) {
|
||||||
|
220.dp
|
||||||
|
} else if (isImageCollage) {
|
||||||
maxCollageWidth
|
maxCollageWidth
|
||||||
} else {
|
} else {
|
||||||
val firstImage =
|
val firstImage =
|
||||||
@@ -843,6 +876,21 @@ fun MessageBubble(
|
|||||||
if (isCallMessage) {
|
if (isCallMessage) {
|
||||||
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
|
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
|
||||||
Modifier
|
Modifier
|
||||||
|
} else if (hasVoiceAttachment) {
|
||||||
|
// Для voice не клипуем содержимое пузыря:
|
||||||
|
// playback-blob может выходить за границы, как в Telegram.
|
||||||
|
Modifier.background(
|
||||||
|
color =
|
||||||
|
if (isSafeSystemMessage) {
|
||||||
|
if (isDarkTheme)
|
||||||
|
Color(0xFF2A2A2D)
|
||||||
|
else Color(0xFFF0F0F4)
|
||||||
|
} else {
|
||||||
|
bubbleColor
|
||||||
|
},
|
||||||
|
shape = bubbleShape
|
||||||
|
)
|
||||||
|
.padding(bubblePadding)
|
||||||
} else {
|
} else {
|
||||||
Modifier.clip(bubbleShape)
|
Modifier.clip(bubbleShape)
|
||||||
.then(
|
.then(
|
||||||
@@ -962,6 +1010,7 @@ fun MessageBubble(
|
|||||||
isOutgoing = message.isOutgoing,
|
isOutgoing = message.isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
|
chachaKeyPlainHex = message.chachaKeyPlainHex,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
onClick = { onReplyClick(reply.messageId) },
|
onClick = { onReplyClick(reply.messageId) },
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
@@ -978,10 +1027,12 @@ fun MessageBubble(
|
|||||||
MessageAttachments(
|
MessageAttachments(
|
||||||
attachments = message.attachments,
|
attachments = message.attachments,
|
||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
|
chachaKeyPlainHex = message.chachaKeyPlainHex,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
isOutgoing = message.isOutgoing,
|
isOutgoing = message.isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
senderPublicKey = senderPublicKey,
|
senderPublicKey = senderPublicKey,
|
||||||
|
senderDisplayName = senderName,
|
||||||
dialogPublicKey = dialogPublicKey,
|
dialogPublicKey = dialogPublicKey,
|
||||||
isGroupChat = isGroupChat,
|
isGroupChat = isGroupChat,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
@@ -1050,7 +1101,32 @@ fun MessageBubble(
|
|||||||
onClick =
|
onClick =
|
||||||
textClickHandler,
|
textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥 Long press для selection
|
onLongClick, // 🔥 Long press для selection
|
||||||
|
onViewCreated = { textViewRef = it },
|
||||||
|
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||||
|
val info = textViewRef?.getLayoutInfo()
|
||||||
|
if (info != null) {
|
||||||
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef,
|
||||||
|
isOwnMessage = message.isOutgoing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||||
|
textSelectionHelper.moveHandle(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
textSelectionHelper.showMagnifier(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
|
onSelectionDragEnd = selectionDragEndHandler
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
@@ -1141,12 +1217,22 @@ fun MessageBubble(
|
|||||||
suppressBubbleTapFromSpan,
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick, // 🔥 Long press для selection
|
||||||
// Long
|
onViewCreated = { textViewRef = it },
|
||||||
// press
|
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
|
||||||
// для
|
val info = textViewRef?.getLayoutInfo()
|
||||||
// selection
|
if (info != null) {
|
||||||
)
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef,
|
||||||
|
isOwnMessage = message.isOutgoing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
Row(
|
Row(
|
||||||
@@ -1245,11 +1331,32 @@ fun MessageBubble(
|
|||||||
suppressBubbleTapFromSpan,
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick, // 🔥 Long press для selection
|
||||||
// Long
|
onViewCreated = { textViewRef = it },
|
||||||
// press
|
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||||
// для
|
val info = textViewRef?.getLayoutInfo()
|
||||||
// selection
|
if (info != null) {
|
||||||
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef,
|
||||||
|
isOwnMessage = message.isOutgoing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||||
|
textSelectionHelper.moveHandle(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
textSelectionHelper.showMagnifier(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
|
onSelectionDragEnd = selectionDragEndHandler
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
@@ -2097,6 +2204,7 @@ fun ReplyBubble(
|
|||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
chachaKey: String = "",
|
chachaKey: String = "",
|
||||||
|
chachaKeyPlainHex: String = "",
|
||||||
privateKey: String = "",
|
privateKey: String = "",
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
@@ -2224,7 +2332,10 @@ fun ReplyBubble(
|
|||||||
cacheKey = "img_${imageAttachment.id}",
|
cacheKey = "img_${imageAttachment.id}",
|
||||||
context = context,
|
context = context,
|
||||||
senderPublicKey = replyData.senderPublicKey,
|
senderPublicKey = replyData.senderPublicKey,
|
||||||
recipientPrivateKey = replyData.recipientPrivateKey
|
recipientPrivateKey = replyData.recipientPrivateKey,
|
||||||
|
chachaKeyPlainHex = replyData.chachaKeyPlainHex.ifEmpty {
|
||||||
|
chachaKeyPlainHex
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (bitmap != null) imageBitmap = bitmap
|
if (bitmap != null) imageBitmap = bitmap
|
||||||
}
|
}
|
||||||
@@ -2302,6 +2413,8 @@ fun ReplyBubble(
|
|||||||
)
|
)
|
||||||
} else if (!hasImage) {
|
} else if (!hasImage) {
|
||||||
val displayText = when {
|
val displayText = when {
|
||||||
|
replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||||
|
replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
else -> "..."
|
else -> "..."
|
||||||
@@ -3540,6 +3653,7 @@ fun ProfilePhotoMenu(
|
|||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
onQrCodeClick: (() -> Unit)? = null,
|
||||||
onSetPhotoClick: () -> Unit,
|
onSetPhotoClick: () -> Unit,
|
||||||
onDeletePhotoClick: (() -> Unit)? = null,
|
onDeletePhotoClick: (() -> Unit)? = null,
|
||||||
hasAvatar: Boolean = false
|
hasAvatar: Boolean = false
|
||||||
@@ -3569,6 +3683,16 @@ fun ProfilePhotoMenu(
|
|||||||
dismissOnClickOutside = true
|
dismissOnClickOutside = true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
onQrCodeClick?.let { onQrClick ->
|
||||||
|
ProfilePhotoMenuItem(
|
||||||
|
icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(TablerIcons.Scan),
|
||||||
|
text = "QR Code",
|
||||||
|
onClick = onQrClick,
|
||||||
|
tintColor = iconColor,
|
||||||
|
textColor = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ProfilePhotoMenuItem(
|
ProfilePhotoMenuItem(
|
||||||
icon = TelegramIcons.AddPhoto,
|
icon = TelegramIcons.AddPhoto,
|
||||||
text = if (hasAvatar) "Set Profile Photo" else "Add Photo",
|
text = if (hasAvatar) "Set Profile Photo" else "Add Photo",
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.Layout
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.RoundRect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class LayoutInfo(
|
||||||
|
val layout: Layout,
|
||||||
|
val windowX: Int,
|
||||||
|
val windowY: Int,
|
||||||
|
val text: CharSequence
|
||||||
|
)
|
||||||
|
|
||||||
|
class TextSelectionHelper {
|
||||||
|
|
||||||
|
var selectionStart by mutableIntStateOf(-1)
|
||||||
|
private set
|
||||||
|
var selectionEnd by mutableIntStateOf(-1)
|
||||||
|
private set
|
||||||
|
var selectedMessageId by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
// True when the selected message is the user's own (blue bubble) — used to pick
|
||||||
|
// white handles against the blue background instead of the default blue handles.
|
||||||
|
var isOwnMessage by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var layoutInfo by mutableStateOf<LayoutInfo?>(null)
|
||||||
|
private set
|
||||||
|
var isActive by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var handleViewProgress by mutableFloatStateOf(0f)
|
||||||
|
private set
|
||||||
|
var movingHandle by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var movingHandleStart by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
// Telegram: isOneTouch = true during initial long-press drag (before finger lifts)
|
||||||
|
var isOneTouch by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
// Telegram: direction not determined yet — first drag decides start or end handle
|
||||||
|
private var movingDirectionSettling = false
|
||||||
|
private var movingOffsetX = 0f
|
||||||
|
private var movingOffsetY = 0f
|
||||||
|
|
||||||
|
var startHandleX by mutableFloatStateOf(0f)
|
||||||
|
var startHandleY by mutableFloatStateOf(0f)
|
||||||
|
var endHandleX by mutableFloatStateOf(0f)
|
||||||
|
var endHandleY by mutableFloatStateOf(0f)
|
||||||
|
|
||||||
|
// Back gesture callback — registered/unregistered by overlay
|
||||||
|
var backCallback: Any? = null
|
||||||
|
|
||||||
|
// Overlay position in window — set by TextSelectionOverlay
|
||||||
|
var overlayWindowX = 0f
|
||||||
|
var overlayWindowY = 0f
|
||||||
|
|
||||||
|
val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
|
||||||
|
|
||||||
|
fun startSelection(
|
||||||
|
messageId: String,
|
||||||
|
info: LayoutInfo,
|
||||||
|
touchX: Int,
|
||||||
|
touchY: Int,
|
||||||
|
view: View?,
|
||||||
|
isOwnMessage: Boolean = false
|
||||||
|
) {
|
||||||
|
this.isOwnMessage = isOwnMessage
|
||||||
|
val layout = info.layout
|
||||||
|
val localX = touchX - info.windowX
|
||||||
|
val localY = touchY - info.windowY
|
||||||
|
|
||||||
|
val line = layout.getLineForVertical(localY)
|
||||||
|
val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||||
|
val offset = layout.getOffsetForHorizontal(line, hx)
|
||||||
|
|
||||||
|
val text = info.text
|
||||||
|
var start = offset
|
||||||
|
var end = offset
|
||||||
|
|
||||||
|
while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start--
|
||||||
|
while (end < text.length && Character.isLetterOrDigit(text[end])) end++
|
||||||
|
|
||||||
|
if (start == end && end < text.length) end++
|
||||||
|
|
||||||
|
selectedMessageId = messageId
|
||||||
|
layoutInfo = info
|
||||||
|
selectionStart = start
|
||||||
|
selectionEnd = end
|
||||||
|
isActive = true
|
||||||
|
handleViewProgress = 1f
|
||||||
|
|
||||||
|
// Telegram: immediately enter drag mode — user can drag without lifting finger
|
||||||
|
movingHandle = true
|
||||||
|
movingDirectionSettling = true
|
||||||
|
isOneTouch = true
|
||||||
|
movingOffsetX = 0f
|
||||||
|
movingOffsetY = 0f
|
||||||
|
|
||||||
|
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
showToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSelectionStart(charOffset: Int) {
|
||||||
|
if (!isActive) return
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
val newStart = charOffset.coerceIn(0, text.length)
|
||||||
|
if (newStart >= selectionEnd) return
|
||||||
|
val changed = newStart != selectionStart
|
||||||
|
selectionStart = newStart
|
||||||
|
if (changed) hapticOnSelectionChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSelectionEnd(charOffset: Int) {
|
||||||
|
if (!isActive) return
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
val newEnd = charOffset.coerceIn(0, text.length)
|
||||||
|
if (newEnd <= selectionStart) return
|
||||||
|
val changed = newEnd != selectionEnd
|
||||||
|
selectionEnd = newEnd
|
||||||
|
if (changed) hapticOnSelectionChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hapticOnSelectionChange() {
|
||||||
|
magnifierView?.performHapticFeedback(
|
||||||
|
HapticFeedbackConstants.TEXT_HANDLE_MOVE,
|
||||||
|
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) {
|
||||||
|
movingHandle = true
|
||||||
|
movingHandleStart = isStart
|
||||||
|
movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX
|
||||||
|
movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveHandle(touchX: Float, touchY: Float) {
|
||||||
|
if (!movingHandle) return
|
||||||
|
val x = (touchX + movingOffsetX).toInt()
|
||||||
|
val y = (touchY + movingOffsetY).toInt()
|
||||||
|
val offset = getCharOffsetFromCoords(x, y)
|
||||||
|
if (offset < 0) return
|
||||||
|
|
||||||
|
// Telegram: first drag determines which handle to move
|
||||||
|
if (movingDirectionSettling) {
|
||||||
|
if (offset < selectionStart) {
|
||||||
|
movingDirectionSettling = false
|
||||||
|
movingHandleStart = true
|
||||||
|
} else if (offset > selectionEnd) {
|
||||||
|
movingDirectionSettling = false
|
||||||
|
movingHandleStart = false
|
||||||
|
} else {
|
||||||
|
return // still within selected word, wait for more movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endHandleDrag() {
|
||||||
|
movingHandle = false
|
||||||
|
movingDirectionSettling = false
|
||||||
|
isOneTouch = false
|
||||||
|
showFloatingToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showToolbar by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun showFloatingToolbar() {
|
||||||
|
if (isInSelectionMode && !movingHandle) {
|
||||||
|
showToolbar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideFloatingToolbar() {
|
||||||
|
showToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnifier: android.widget.Magnifier? = null
|
||||||
|
private var magnifierView: View? = null
|
||||||
|
|
||||||
|
fun setMagnifierView(view: View?) {
|
||||||
|
magnifierView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||||
|
val view = magnifierView ?: return
|
||||||
|
if (!movingHandle) return
|
||||||
|
if (magnifier == null) {
|
||||||
|
magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
|
android.widget.Magnifier.Builder(view)
|
||||||
|
.setSize(240, 64)
|
||||||
|
.setCornerRadius(12f)
|
||||||
|
.setElevation(4f)
|
||||||
|
.setDefaultSourceToMagnifierOffset(0, -96)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
android.widget.Magnifier(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val info = layoutInfo ?: return
|
||||||
|
|
||||||
|
// Magnifier should show at the HANDLE position (current char), not finger
|
||||||
|
// Use handle X for horizontal, and line center for vertical
|
||||||
|
val handleX = if (movingHandleStart) startHandleX else endHandleX
|
||||||
|
val handleY = if (movingHandleStart) startHandleY else endHandleY
|
||||||
|
val activeOffset = if (movingHandleStart) selectionStart else selectionEnd
|
||||||
|
val layout = info.layout
|
||||||
|
val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length))
|
||||||
|
val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f
|
||||||
|
|
||||||
|
// Convert to view-local coordinates
|
||||||
|
val viewLoc = IntArray(2)
|
||||||
|
view.getLocationInWindow(viewLoc)
|
||||||
|
val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat())
|
||||||
|
val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat())
|
||||||
|
magnifier?.show(sourceX, sourceY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideMagnifier() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||||
|
magnifier?.dismiss()
|
||||||
|
magnifier = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int {
|
||||||
|
val info = layoutInfo ?: return -1
|
||||||
|
// overlay-local → text-local: subtract text position relative to overlay
|
||||||
|
val textLocalX = overlayLocalX - (info.windowX - overlayWindowX)
|
||||||
|
val textLocalY = overlayLocalY - (info.windowY - overlayWindowY)
|
||||||
|
val layout = info.layout
|
||||||
|
val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height))
|
||||||
|
val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||||
|
return layout.getOffsetForHorizontal(line, hx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedText(): CharSequence? {
|
||||||
|
if (!isInSelectionMode) return null
|
||||||
|
val text = layoutInfo?.text ?: return null
|
||||||
|
val start = selectionStart.coerceIn(0, text.length)
|
||||||
|
val end = selectionEnd.coerceIn(start, text.length)
|
||||||
|
return text.subSequence(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copySelectedText(context: Context) {
|
||||||
|
val selectedText = getSelectedText() ?: return
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText))
|
||||||
|
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAll() {
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
selectionStart = 0
|
||||||
|
selectionEnd = text.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
selectionStart = -1
|
||||||
|
selectionEnd = -1
|
||||||
|
selectedMessageId = null
|
||||||
|
layoutInfo = null
|
||||||
|
isActive = false
|
||||||
|
handleViewProgress = 0f
|
||||||
|
movingHandle = false
|
||||||
|
movingDirectionSettling = false
|
||||||
|
isOneTouch = false
|
||||||
|
showToolbar = false
|
||||||
|
hideMagnifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HandleSize = 22.dp
|
||||||
|
private val HandleInset = 8.dp
|
||||||
|
private val HighlightCorner = 6.dp
|
||||||
|
private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
|
||||||
|
private val HandleColor = PrimaryBlue
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FloatingToolbarPopup(
|
||||||
|
helper: TextSelectionHelper
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(helper.isActive, helper.movingHandle) {
|
||||||
|
if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
|
||||||
|
delay(200)
|
||||||
|
helper.showFloatingToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!helper.showToolbar || !helper.isInSelectionMode) return
|
||||||
|
|
||||||
|
val info = helper.layoutInfo ?: return
|
||||||
|
val layout = info.layout
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||||
|
|
||||||
|
// Toolbar positioned ABOVE selection top, in overlay-local coordinates
|
||||||
|
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||||
|
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||||
|
(info.windowY - helper.overlayWindowY)
|
||||||
|
// Toolbar is ~48dp tall + 8dp gap above selection
|
||||||
|
val toolbarOffsetPx = with(density) { 56.dp.toPx() }
|
||||||
|
val toolbarWidthPx = with(density) { 200.dp.toPx() }
|
||||||
|
val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
|
||||||
|
val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
|
||||||
|
|
||||||
|
Popup(
|
||||||
|
alignment = Alignment.TopStart,
|
||||||
|
offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.shadow(4.dp, RoundedCornerShape(8.dp))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0xFF333333))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Copy",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { helper.copySelectedText(context) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
val allSelected = helper.selectionStart <= 0 &&
|
||||||
|
helper.selectionEnd >= info.text.length
|
||||||
|
if (!allSelected) {
|
||||||
|
Text(
|
||||||
|
text = "Select All",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
helper.selectAll()
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextSelectionOverlay(
|
||||||
|
helper: TextSelectionHelper,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (!helper.isInSelectionMode) return
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val handleSizePx = with(density) { HandleSize.toPx() }
|
||||||
|
val handleInsetPx = with(density) { HandleInset.toPx() }
|
||||||
|
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
|
||||||
|
// Read isOwnMessage at composition level so Canvas properly invalidates on change.
|
||||||
|
// On own (blue) bubbles use the light-blue typing color — reads better than pure white.
|
||||||
|
val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor
|
||||||
|
val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor
|
||||||
|
|
||||||
|
// Block predictive back gesture completely during text selection.
|
||||||
|
// BackHandler alone doesn't prevent the swipe animation on Android 13+
|
||||||
|
// with enableOnBackInvokedCallback=true. We must register an
|
||||||
|
// OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it.
|
||||||
|
val activity = LocalContext.current as? android.app.Activity
|
||||||
|
LaunchedEffect(helper.isActive) {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) {
|
||||||
|
if (helper.isActive) {
|
||||||
|
val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ }
|
||||||
|
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
|
||||||
|
android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb
|
||||||
|
)
|
||||||
|
helper.backCallback = cb
|
||||||
|
} else {
|
||||||
|
helper.backCallback?.let { cb ->
|
||||||
|
runCatching {
|
||||||
|
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(
|
||||||
|
cb as android.window.OnBackInvokedCallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.backCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback for Android < 13
|
||||||
|
androidx.activity.compose.BackHandler(enabled = helper.isActive) {
|
||||||
|
// consumed — no navigation back while selecting
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val pos = coords.positionInWindow()
|
||||||
|
helper.overlayWindowX = pos.x
|
||||||
|
helper.overlayWindowY = pos.y
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
FloatingToolbarPopup(helper = helper)
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(helper.isActive) {
|
||||||
|
if (!helper.isActive) return@pointerInput
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.firstOrNull() ?: continue
|
||||||
|
|
||||||
|
when {
|
||||||
|
change.pressed && !helper.movingHandle -> {
|
||||||
|
val x = change.position.x
|
||||||
|
val y = change.position.y
|
||||||
|
val startRect = Rect(
|
||||||
|
helper.startHandleX - handleSizePx / 2 - handleInsetPx,
|
||||||
|
helper.startHandleY - handleInsetPx,
|
||||||
|
helper.startHandleX + handleSizePx / 2 + handleInsetPx,
|
||||||
|
helper.startHandleY + handleSizePx + handleInsetPx
|
||||||
|
)
|
||||||
|
val endRect = Rect(
|
||||||
|
helper.endHandleX - handleSizePx / 2 - handleInsetPx,
|
||||||
|
helper.endHandleY - handleInsetPx,
|
||||||
|
helper.endHandleX + handleSizePx / 2 + handleInsetPx,
|
||||||
|
helper.endHandleY + handleSizePx + handleInsetPx
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
startRect.contains(Offset(x, y)) -> {
|
||||||
|
helper.beginHandleDrag(isStart = true, x, y)
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
endRect.contains(Offset(x, y)) -> {
|
||||||
|
helper.beginHandleDrag(isStart = false, x, y)
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
helper.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
change.pressed && helper.movingHandle -> {
|
||||||
|
helper.moveHandle(change.position.x, change.position.y)
|
||||||
|
helper.showMagnifier(change.position.x, change.position.y)
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
!change.pressed && helper.movingHandle -> {
|
||||||
|
helper.hideMagnifier()
|
||||||
|
helper.endHandleDrag()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val info = helper.layoutInfo ?: return@Canvas
|
||||||
|
val layout = info.layout
|
||||||
|
val text = info.text
|
||||||
|
|
||||||
|
// Convert window coords to overlay-local coords
|
||||||
|
val offsetX = info.windowX - helper.overlayWindowX
|
||||||
|
val offsetY = info.windowY - helper.overlayWindowY
|
||||||
|
|
||||||
|
val startOffset = helper.selectionStart.coerceIn(0, text.length)
|
||||||
|
val endOffset = helper.selectionEnd.coerceIn(0, text.length)
|
||||||
|
if (startOffset >= endOffset) return@Canvas
|
||||||
|
|
||||||
|
val startLine = layout.getLineForOffset(startOffset)
|
||||||
|
val endLine = layout.getLineForOffset(endOffset)
|
||||||
|
|
||||||
|
// Padding around highlight for breathing room
|
||||||
|
val padH = 3.dp.toPx()
|
||||||
|
val padV = 2.dp.toPx()
|
||||||
|
|
||||||
|
// Build a single unified Path from all per-line rects, then fill once.
|
||||||
|
// This avoids double-alpha artifacts where adjacent lines' padding overlaps.
|
||||||
|
val highlightPath = Path()
|
||||||
|
for (line in startLine..endLine) {
|
||||||
|
// Only pad the outer edges (top of first line, bottom of last line).
|
||||||
|
// Inner edges meet at lineBottom == nextLineTop so the union fills fully.
|
||||||
|
val topPad = if (line == startLine) padV else 0f
|
||||||
|
val bottomPad = if (line == endLine) padV else 0f
|
||||||
|
val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad
|
||||||
|
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad
|
||||||
|
val left = if (line == startLine) {
|
||||||
|
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
|
||||||
|
} else {
|
||||||
|
layout.getLineLeft(line) + offsetX - padH
|
||||||
|
}
|
||||||
|
val right = if (line == endLine) {
|
||||||
|
layout.getPrimaryHorizontal(endOffset) + offsetX + padH
|
||||||
|
} else {
|
||||||
|
layout.getLineRight(line) + offsetX + padH
|
||||||
|
}
|
||||||
|
highlightPath.addRoundRect(
|
||||||
|
RoundRect(
|
||||||
|
rect = Rect(left, lineTop, right, lineBottom),
|
||||||
|
cornerRadius = CornerRadius(highlightCornerPx)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawPath(path = highlightPath, color = highlightColor)
|
||||||
|
|
||||||
|
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||||
|
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
|
||||||
|
val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||||
|
val endHy = layout.getLineBottom(endLine).toFloat() + offsetY
|
||||||
|
|
||||||
|
helper.startHandleX = startHx
|
||||||
|
helper.startHandleY = startHy
|
||||||
|
helper.endHandleX = endHx
|
||||||
|
helper.endHandleY = endHy
|
||||||
|
|
||||||
|
drawStartHandle(startHx, startHy, handleSizePx, handleColor)
|
||||||
|
drawEndHandle(endHx, endHy, handleSizePx, handleColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) {
|
||||||
|
val half = size / 2f
|
||||||
|
drawCircle(
|
||||||
|
color = color,
|
||||||
|
radius = half,
|
||||||
|
center = Offset(x, y + half)
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(x, y),
|
||||||
|
size = Size(half, half)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) {
|
||||||
|
val half = size / 2f
|
||||||
|
drawCircle(
|
||||||
|
color = color,
|
||||||
|
radius = half,
|
||||||
|
center = Offset(x, y + half)
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(x - half, y),
|
||||||
|
size = Size(half, half)
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -558,6 +558,10 @@ fun AppleEmojiText(
|
|||||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||||
|
onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||||
|
onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||||
|
onSelectionDragEnd: (() -> Unit)? = null,
|
||||||
|
onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null,
|
||||||
minHeightMultiplier: Float = 1.5f
|
minHeightMultiplier: Float = 1.5f
|
||||||
) {
|
) {
|
||||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||||
@@ -601,6 +605,10 @@ fun AppleEmojiText(
|
|||||||
enableMentionHighlight(enableMentions)
|
enableMentionHighlight(enableMentions)
|
||||||
setOnMentionClickListener(onMentionClick)
|
setOnMentionClickListener(onMentionClick)
|
||||||
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
|
onTextLongPressCallback = onTextLongPress
|
||||||
|
this.onSelectionDrag = onSelectionDrag
|
||||||
|
this.onSelectionDragEnd = onSelectionDragEnd
|
||||||
|
onViewCreated?.invoke(this)
|
||||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||||
val canUseTextViewClick = !enableLinks
|
val canUseTextViewClick = !enableLinks
|
||||||
setOnClickListener(
|
setOnClickListener(
|
||||||
@@ -634,6 +642,9 @@ fun AppleEmojiText(
|
|||||||
view.enableMentionHighlight(enableMentions)
|
view.enableMentionHighlight(enableMentions)
|
||||||
view.setOnMentionClickListener(onMentionClick)
|
view.setOnMentionClickListener(onMentionClick)
|
||||||
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
|
view.onTextLongPressCallback = onTextLongPress
|
||||||
|
view.onSelectionDrag = onSelectionDrag
|
||||||
|
view.onSelectionDragEnd = onSelectionDragEnd
|
||||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||||
val canUseTextViewClick = !enableLinks
|
val canUseTextViewClick = !enableLinks
|
||||||
view.setOnClickListener(
|
view.setOnClickListener(
|
||||||
@@ -695,13 +706,23 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
// 🔥 Long press callback для selection в MessageBubble
|
// 🔥 Long press callback для selection в MessageBubble
|
||||||
var onLongClickCallback: (() -> Unit)? = null
|
var onLongClickCallback: (() -> Unit)? = null
|
||||||
|
var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||||
|
// Telegram flow: forward drag/up events after long press fires
|
||||||
|
var onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||||
|
var onSelectionDragEnd: (() -> Unit)? = null
|
||||||
private var downOnClickableSpan: Boolean = false
|
private var downOnClickableSpan: Boolean = false
|
||||||
private var suppressPerformClickOnce: Boolean = false
|
private var suppressPerformClickOnce: Boolean = false
|
||||||
|
private var selectionDragActive: Boolean = false
|
||||||
|
|
||||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
override fun onLongPress(e: MotionEvent) {
|
override fun onLongPress(e: MotionEvent) {
|
||||||
if (!downOnClickableSpan) {
|
if (downOnClickableSpan) return
|
||||||
|
if (onTextLongPressCallback != null) {
|
||||||
|
onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt())
|
||||||
|
selectionDragActive = true
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag
|
||||||
|
} else {
|
||||||
onLongClickCallback?.invoke()
|
onLongClickCallback?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
downOnClickableSpan = isTouchOnClickableSpan(event)
|
downOnClickableSpan = isTouchOnClickableSpan(event)
|
||||||
suppressPerformClickOnce = downOnClickableSpan
|
suppressPerformClickOnce = downOnClickableSpan
|
||||||
|
selectionDragActive = false
|
||||||
if (downOnClickableSpan) {
|
if (downOnClickableSpan) {
|
||||||
clickableSpanPressStartCallback?.invoke()
|
clickableSpanPressStartCallback?.invoke()
|
||||||
parent?.requestDisallowInterceptTouchEvent(true)
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
if (selectionDragActive) {
|
||||||
|
onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
MotionEvent.ACTION_CANCEL,
|
MotionEvent.ACTION_CANCEL,
|
||||||
MotionEvent.ACTION_UP -> {
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (selectionDragActive) {
|
||||||
|
selectionDragActive = false
|
||||||
|
onSelectionDragEnd?.invoke()
|
||||||
|
downOnClickableSpan = false
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
downOnClickableSpan = false
|
downOnClickableSpan = false
|
||||||
parent?.requestDisallowInterceptTouchEvent(false)
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Позволяем GestureDetector обработать событие (для long press)
|
|
||||||
gestureDetector.onTouchEvent(event)
|
gestureDetector.onTouchEvent(event)
|
||||||
// Передаем событие дальше для обработки ссылок
|
|
||||||
return super.dispatchTouchEvent(event)
|
return super.dispatchTouchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +855,18 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLayoutInfo(): com.rosetta.messenger.ui.chats.components.LayoutInfo? {
|
||||||
|
val l = layout ?: return null
|
||||||
|
val loc = IntArray(2)
|
||||||
|
getLocationInWindow(loc)
|
||||||
|
return com.rosetta.messenger.ui.chats.components.LayoutInfo(
|
||||||
|
layout = l,
|
||||||
|
windowX = loc[0] + totalPaddingLeft,
|
||||||
|
windowY = loc[1] + totalPaddingTop,
|
||||||
|
text = text ?: return null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setTextWithEmojis(text: String) {
|
fun setTextWithEmojis(text: String) {
|
||||||
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
||||||
val processMentions = mentionsEnabled && !isLargeText
|
val processMentions = mentionsEnabled && !isLargeText
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -136,6 +139,8 @@ fun SwipeBackContainer(
|
|||||||
propagateBackgroundProgress: Boolean = true,
|
propagateBackgroundProgress: Boolean = true,
|
||||||
deferToChildren: Boolean = false,
|
deferToChildren: Boolean = false,
|
||||||
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
|
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
|
||||||
|
// Return true to cancel the swipe — screen bounces back and onBack is NOT called.
|
||||||
|
onInterceptSwipeBack: () -> Boolean = { false },
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||||
@@ -160,7 +165,7 @@ fun SwipeBackContainer(
|
|||||||
// Alpha animation for fade-in entry
|
// Alpha animation for fade-in entry
|
||||||
val alphaAnimatable = remember { Animatable(0f) }
|
val alphaAnimatable = remember { Animatable(0f) }
|
||||||
|
|
||||||
// Drag state - direct update without animation
|
// Drag state
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -177,6 +182,7 @@ fun SwipeBackContainer(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val lifecycleOwner = view.findViewTreeLifecycleOwner()
|
||||||
val dismissKeyboard: () -> Unit = {
|
val dismissKeyboard: () -> Unit = {
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
@@ -187,21 +193,16 @@ fun SwipeBackContainer(
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
|
fun computeCurrentOffset(): Float {
|
||||||
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
val base = if (isDragging) dragOffset else offsetAnimatable.value
|
||||||
val enterOffset =
|
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||||
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
enterOffsetAnimatable.value
|
||||||
enterOffsetAnimatable.value
|
} else 0f
|
||||||
} else {
|
return base + enter
|
||||||
0f
|
}
|
||||||
}
|
|
||||||
val currentOffset = baseOffset + enterOffset
|
|
||||||
|
|
||||||
// Current alpha: use animatable during fade animations, otherwise 1
|
// Current alpha: use animatable during fade animations, otherwise 1
|
||||||
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
||||||
|
|
||||||
// Scrim alpha based on swipe progress
|
|
||||||
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
|
|
||||||
val sharedOwnerId = SwipeBackSharedProgress.ownerId
|
val sharedOwnerId = SwipeBackSharedProgress.ownerId
|
||||||
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
||||||
val sharedProgress = SwipeBackSharedProgress.progress
|
val sharedProgress = SwipeBackSharedProgress.progress
|
||||||
@@ -239,6 +240,21 @@ fun SwipeBackContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun forceResetSwipeState() {
|
||||||
|
isDragging = false
|
||||||
|
dragOffset = 0f
|
||||||
|
clearSharedSwipeProgressIfOwner()
|
||||||
|
scope.launch {
|
||||||
|
offsetAnimatable.snapTo(0f)
|
||||||
|
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||||
|
enterOffsetAnimatable.snapTo(0f)
|
||||||
|
}
|
||||||
|
if (shouldShow && !isAnimatingOut) {
|
||||||
|
alphaAnimatable.snapTo(1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle visibility changes
|
// Handle visibility changes
|
||||||
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
||||||
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
|
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
|
||||||
@@ -292,10 +308,34 @@ fun SwipeBackContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } }
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
if (lifecycleOwner == null) {
|
||||||
|
onDispose { }
|
||||||
|
} else {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||||
|
forceResetSwipeState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
forceResetSwipeState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
|
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
|
||||||
|
|
||||||
|
val currentOffset = computeCurrentOffset()
|
||||||
|
val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
|
||||||
|
val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize().graphicsLayer {
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
@@ -346,13 +386,15 @@ fun SwipeBackContainer(
|
|||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
var keyboardHiddenForGesture = false
|
var keyboardHiddenForGesture = false
|
||||||
|
var resetOnFinally = true
|
||||||
|
|
||||||
// deferToChildren=true: pre-slop uses Main pass so children
|
// deferToChildren=true: pre-slop uses Main pass so children
|
||||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||||
// deferToChildren=false (default): always use Initial pass
|
// deferToChildren=false (default): always use Initial pass
|
||||||
// to intercept before children (original behavior).
|
// to intercept before children (original behavior).
|
||||||
// Post-claim: always Initial to block children.
|
// Post-claim: always Initial to block children.
|
||||||
while (true) {
|
try {
|
||||||
|
while (true) {
|
||||||
val pass =
|
val pass =
|
||||||
if (startedSwipe || !deferToChildren)
|
if (startedSwipe || !deferToChildren)
|
||||||
PointerEventPass.Initial
|
PointerEventPass.Initial
|
||||||
@@ -365,6 +407,7 @@ fun SwipeBackContainer(
|
|||||||
?: break
|
?: break
|
||||||
|
|
||||||
if (change.changedToUpIgnoreConsumed()) {
|
if (change.changedToUpIgnoreConsumed()) {
|
||||||
|
resetOnFinally = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +486,13 @@ fun SwipeBackContainer(
|
|||||||
)
|
)
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Сбрасываем только при отмене/прерывании жеста.
|
||||||
|
// При обычном UP сброс делаем позже, чтобы не было рывка.
|
||||||
|
if (resetOnFinally && isDragging) {
|
||||||
|
forceResetSwipeState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle drag end
|
// Handle drag end
|
||||||
@@ -475,6 +525,32 @@ fun SwipeBackContainer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (shouldComplete) {
|
if (shouldComplete) {
|
||||||
|
// Intercept: if owner handled back locally (e.g. clear
|
||||||
|
// message selection), bounce back without exiting.
|
||||||
|
if (onInterceptSwipeBack()) {
|
||||||
|
dismissKeyboard()
|
||||||
|
offsetAnimatable.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec =
|
||||||
|
tween(
|
||||||
|
durationMillis =
|
||||||
|
ANIMATION_DURATION_EXIT,
|
||||||
|
easing =
|
||||||
|
TelegramEasing
|
||||||
|
),
|
||||||
|
block = {
|
||||||
|
updateSharedSwipeProgress(
|
||||||
|
progress =
|
||||||
|
value /
|
||||||
|
screenWidthPx,
|
||||||
|
active = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dragOffset = 0f
|
||||||
|
clearSharedSwipeProgressIfOwner()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
offsetAnimatable.animateTo(
|
offsetAnimatable.animateTo(
|
||||||
targetValue = screenWidthPx,
|
targetValue = screenWidthPx,
|
||||||
animationSpec =
|
animationSpec =
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.onboarding
|
|||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -90,87 +92,17 @@ fun OnboardingScreen(
|
|||||||
|
|
||||||
// Theme transition animation
|
// Theme transition animation
|
||||||
var isTransitioning by remember { mutableStateOf(false) }
|
var isTransitioning by remember { mutableStateOf(false) }
|
||||||
var transitionProgress by remember { mutableStateOf(0f) }
|
val transitionRadius = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||||
var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||||
var shouldUpdateStatusBar by remember { mutableStateOf(false) }
|
|
||||||
var hasInitialized by remember { mutableStateOf(false) }
|
var hasInitialized by remember { mutableStateOf(false) }
|
||||||
var previousTheme by remember { mutableStateOf(isDarkTheme) }
|
var previousTheme by remember { mutableStateOf(isDarkTheme) }
|
||||||
var targetTheme by remember { mutableStateOf(isDarkTheme) }
|
var targetTheme by remember { mutableStateOf(isDarkTheme) }
|
||||||
|
var rootSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) { hasInitialized = true }
|
LaunchedEffect(Unit) { hasInitialized = true }
|
||||||
|
|
||||||
LaunchedEffect(isTransitioning) {
|
|
||||||
if (isTransitioning) {
|
|
||||||
shouldUpdateStatusBar = false
|
|
||||||
val duration = 800f
|
|
||||||
val startTime = System.currentTimeMillis()
|
|
||||||
while (transitionProgress < 1f) {
|
|
||||||
val elapsed = System.currentTimeMillis() - startTime
|
|
||||||
transitionProgress = (elapsed / duration).coerceAtMost(1f)
|
|
||||||
|
|
||||||
delay(16) // ~60fps
|
|
||||||
}
|
|
||||||
// Update status bar icons after animation is completely finished
|
|
||||||
shouldUpdateStatusBar = true
|
|
||||||
delay(50) // Small delay to ensure UI updates
|
|
||||||
isTransitioning = false
|
|
||||||
transitionProgress = 0f
|
|
||||||
shouldUpdateStatusBar = false
|
|
||||||
previousTheme = targetTheme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate navigation bar color starting at 80% of wave animation
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val isGestureNavigation = remember(view.context) {
|
|
||||||
NavigationModeUtils.isGestureNavigation(view.context)
|
|
||||||
}
|
|
||||||
LaunchedEffect(isTransitioning, transitionProgress) {
|
|
||||||
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
|
||||||
val window = (view.context as android.app.Activity).window
|
|
||||||
// Map 0.8-1.0 to 0-1 for smooth interpolation
|
|
||||||
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
|
|
||||||
|
|
||||||
val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF
|
|
||||||
val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF
|
|
||||||
|
|
||||||
val r1 = (oldColor shr 16 and 0xFF)
|
|
||||||
val g1 = (oldColor shr 8 and 0xFF)
|
|
||||||
val b1 = (oldColor and 0xFF)
|
|
||||||
val r2 = (newColor shr 16 and 0xFF)
|
|
||||||
val g2 = (newColor shr 8 and 0xFF)
|
|
||||||
val b2 = (newColor and 0xFF)
|
|
||||||
|
|
||||||
val r = (r1 + (r2 - r1) * navProgress).toInt()
|
|
||||||
val g = (g1 + (g2 - g1) * navProgress).toInt()
|
|
||||||
val b = (b1 + (b2 - b1) * navProgress).toInt()
|
|
||||||
|
|
||||||
window.navigationBarColor =
|
|
||||||
(0xFF000000 or
|
|
||||||
(r.toLong() shl 16) or
|
|
||||||
(g.toLong() shl 8) or
|
|
||||||
b.toLong())
|
|
||||||
.toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status bar icons when animation finishes
|
|
||||||
LaunchedEffect(shouldUpdateStatusBar) {
|
|
||||||
if (shouldUpdateStatusBar && !view.isInEditMode) {
|
|
||||||
val window = (view.context as android.app.Activity).window
|
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
|
||||||
|
|
||||||
// Navigation bar: показываем только если есть нативные кнопки
|
|
||||||
NavigationModeUtils.applyNavigationBarVisibility(
|
|
||||||
window = window,
|
|
||||||
insetsController = insetsController,
|
|
||||||
context = view.context,
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial navigation bar color only on first launch
|
// Set initial navigation bar color only on first launch
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -221,7 +153,9 @@ fun OnboardingScreen(
|
|||||||
label = "indicatorColor"
|
label = "indicatorColor"
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
|
Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()
|
||||||
|
.onGloballyPositioned { rootSize = it.size }
|
||||||
|
) {
|
||||||
// Base background - shows the OLD theme color during transition
|
// Base background - shows the OLD theme color during transition
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -237,15 +171,11 @@ fun OnboardingScreen(
|
|||||||
// Circular reveal overlay - draws the NEW theme color expanding
|
// Circular reveal overlay - draws the NEW theme color expanding
|
||||||
if (isTransitioning) {
|
if (isTransitioning) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val maxRadius = hypot(size.width, size.height)
|
|
||||||
val radius = maxRadius * transitionProgress
|
|
||||||
|
|
||||||
// Draw the NEW theme color expanding from click point
|
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color =
|
color =
|
||||||
if (targetTheme) OnboardingBackground
|
if (targetTheme) OnboardingBackground
|
||||||
else OnboardingBackgroundLight,
|
else OnboardingBackgroundLight,
|
||||||
radius = radius,
|
radius = transitionRadius.value,
|
||||||
center = clickPosition
|
center = clickPosition
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -260,6 +190,22 @@ fun OnboardingScreen(
|
|||||||
clickPosition = position
|
clickPosition = position
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
onThemeToggle()
|
onThemeToggle()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val maxR = hypot(
|
||||||
|
rootSize.width.toFloat(),
|
||||||
|
rootSize.height.toFloat()
|
||||||
|
).coerceAtLeast(1f)
|
||||||
|
transitionRadius.snapTo(0f)
|
||||||
|
transitionRadius.animateTo(
|
||||||
|
targetValue = maxR,
|
||||||
|
animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f))
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
isTransitioning = false
|
||||||
|
previousTheme = targetTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
||||||
|
// Local dark/light state — independent from the global app theme
|
||||||
// Auto-switch to matching theme group when app theme changes
|
var localIsDark by remember { mutableStateOf(isDarkTheme) }
|
||||||
LaunchedEffect(isDarkTheme) {
|
|
||||||
val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
|
|
||||||
if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
|
|
||||||
// Map to same position in the other group
|
|
||||||
val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
|
|
||||||
selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val theme = qrThemes[selectedThemeIndex]
|
val theme = qrThemes[selectedThemeIndex]
|
||||||
|
|
||||||
@@ -132,6 +124,13 @@ fun MyQrCodeScreen(
|
|||||||
var rootSize by remember { mutableStateOf(IntSize.Zero) }
|
var rootSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
var lastRevealTime by remember { mutableLongStateOf(0L) }
|
var lastRevealTime by remember { mutableLongStateOf(0L) }
|
||||||
val revealCooldownMs = 600L
|
val revealCooldownMs = 600L
|
||||||
|
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
|
// Prewarm bitmap on screen appear
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(300)
|
||||||
|
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
fun startReveal(newIndex: Int, center: Offset) {
|
fun startReveal(newIndex: Int, center: Offset) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -142,7 +141,8 @@ fun MyQrCodeScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
|
val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
prewarmedBitmap = null
|
||||||
if (snapshot == null) {
|
if (snapshot == null) {
|
||||||
selectedThemeIndex = newIndex
|
selectedThemeIndex = newIndex
|
||||||
return
|
return
|
||||||
@@ -264,7 +264,7 @@ fun MyQrCodeScreen(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||||
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
|
color = if (localIsDark) Color(0xFF1C1C1E) else Color.White,
|
||||||
shadowElevation = 16.dp
|
shadowElevation = 16.dp
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -291,30 +291,31 @@ fun MyQrCodeScreen(
|
|||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(TablerIcons.X, contentDescription = "Close",
|
Icon(TablerIcons.X, contentDescription = "Close",
|
||||||
tint = if (isDarkTheme) Color.White else Color.Black)
|
tint = if (localIsDark) Color.White else Color.Black)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
|
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
|
||||||
color = if (isDarkTheme) Color.White else Color.Black)
|
color = if (localIsDark) Color.White else Color.Black)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Snapshot → toggle theme → circular reveal
|
// Snapshot → toggle LOCAL theme → circular reveal
|
||||||
|
// Does NOT toggle the global app theme
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
||||||
lastRevealTime = now
|
lastRevealTime = now
|
||||||
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
|
val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
prewarmedBitmap = null
|
||||||
if (snapshot != null) {
|
if (snapshot != null) {
|
||||||
val maxR = maxRevealRadius(themeButtonPos, rootSize)
|
val maxR = maxRevealRadius(themeButtonPos, rootSize)
|
||||||
revealActive = true
|
revealActive = true
|
||||||
revealCenter = themeButtonPos
|
revealCenter = themeButtonPos
|
||||||
revealSnapshot = snapshot.asImageBitmap()
|
revealSnapshot = snapshot.asImageBitmap()
|
||||||
// Switch to matching wallpaper in new theme
|
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||||
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
|
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||||
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
|
||||||
selectedThemeIndex = newIndex
|
selectedThemeIndex = newIndex
|
||||||
onToggleTheme()
|
localIsDark = !localIsDark
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
revealRadius.snapTo(0f)
|
revealRadius.snapTo(0f)
|
||||||
@@ -328,11 +329,8 @@ fun MyQrCodeScreen(
|
|||||||
revealActive = false
|
revealActive = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// drawToBitmap failed — skip
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: cooldown active — ignore tap
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.onGloballyPositioned { coords ->
|
modifier = Modifier.onGloballyPositioned { coords ->
|
||||||
val pos = coords.positionInRoot()
|
val pos = coords.positionInRoot()
|
||||||
@@ -341,9 +339,9 @@ fun MyQrCodeScreen(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
|
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||||
contentDescription = "Toggle theme",
|
contentDescription = "Toggle theme",
|
||||||
tint = if (isDarkTheme) Color.White else Color.Black
|
tint = if (localIsDark) Color.White else Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +349,7 @@ fun MyQrCodeScreen(
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Wallpaper selector — show current theme's wallpapers
|
// Wallpaper selector — show current theme's wallpapers
|
||||||
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
|
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
@@ -385,7 +383,7 @@ fun MyQrCodeScreen(
|
|||||||
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
}
|
}
|
||||||
Icon(TablerIcons.Scan, contentDescription = null,
|
Icon(TablerIcons.Scan, contentDescription = null,
|
||||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
||||||
modifier = Modifier.size(22.dp))
|
modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package com.rosetta.messenger.ui.settings
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.ChevronLeft
|
||||||
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AppIconOption(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val aliasName: String,
|
||||||
|
val iconRes: Int,
|
||||||
|
val previewBg: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
private val iconOptions = listOf(
|
||||||
|
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)),
|
||||||
|
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White),
|
||||||
|
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White),
|
||||||
|
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppIconScreen(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val prefs = remember { PreferencesManager(context) }
|
||||||
|
var currentIcon by remember { mutableStateOf("default") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
currentIcon = prefs.appIcon.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
|
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
DisposableEffect(isDarkTheme) {
|
||||||
|
val window = (view.context as android.app.Activity).window
|
||||||
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
|
val prev = insetsController.isAppearanceLightStatusBars
|
||||||
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
|
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// TOP BAR — same style as SafetyScreen
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = backgroundColor
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||||
|
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.ChevronLeft,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "App Icon",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// CONTENT
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
Text(
|
||||||
|
text = "CHOOSE ICON",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Icon cards in grouped surface (Telegram style)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = surfaceColor
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
iconOptions.forEachIndexed { index, option ->
|
||||||
|
val isSelected = currentIcon == option.id
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
if (!isSelected) {
|
||||||
|
scope.launch {
|
||||||
|
changeAppIcon(context, prefs, option.id)
|
||||||
|
currentIcon = option.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Icon preview
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(option.previewBg),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val imgSize = if (option.id == "default") 52.dp else 44.dp
|
||||||
|
val imgScale = if (option.id == "default")
|
||||||
|
android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
else
|
||||||
|
android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
androidx.compose.ui.viewinterop.AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
android.widget.ImageView(ctx).apply {
|
||||||
|
setImageResource(option.iconRes)
|
||||||
|
scaleType = imgScale
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(imgSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
|
||||||
|
// Label + subtitle
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = option.label,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = option.subtitle,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkmark
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider between items (not after last)
|
||||||
|
if (index < iconOptions.lastIndex) {
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(start = 82.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = dividerColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info text below
|
||||||
|
Text(
|
||||||
|
text = "The app icon and name on your home screen will change. Rosetta will continue to work normally. The launcher may take a moment to update.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
lineHeight = 18.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeAppIcon(context: Context, prefs: PreferencesManager, newIconId: String) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val packageName = context.packageName
|
||||||
|
|
||||||
|
iconOptions.forEach { option ->
|
||||||
|
val component = ComponentName(packageName, "$packageName${option.aliasName}")
|
||||||
|
pm.setComponentEnabledSetting(
|
||||||
|
component,
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||||
|
PackageManager.DONT_KILL_APP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val selected = iconOptions.first { it.id == newIconId }
|
||||||
|
val component = ComponentName(packageName, "$packageName${selected.aliasName}")
|
||||||
|
pm.setComponentEnabledSetting(
|
||||||
|
component,
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||||
|
PackageManager.DONT_KILL_APP
|
||||||
|
)
|
||||||
|
|
||||||
|
prefs.setAppIcon(newIconId)
|
||||||
|
Toast.makeText(context, "Icon changed to ${selected.label}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ fun AppearanceScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onBlurColorChange: (String) -> Unit,
|
onBlurColorChange: (String) -> Unit,
|
||||||
onToggleTheme: () -> Unit = {},
|
onToggleTheme: () -> Unit = {},
|
||||||
|
onAppIconClick: () -> Unit = {},
|
||||||
accountPublicKey: String = "",
|
accountPublicKey: String = "",
|
||||||
accountName: String = "",
|
accountName: String = "",
|
||||||
avatarRepository: AvatarRepository? = null
|
avatarRepository: AvatarRepository? = null
|
||||||
@@ -282,6 +283,49 @@ fun AppearanceScreen(
|
|||||||
lineHeight = 18.sp
|
lineHeight = 18.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// APP ICON SECTION
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
Text(
|
||||||
|
text = "APP ICON",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onAppIconClick() }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Change App Icon",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Disguise Rosetta as a calculator, weather app, or notes.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
lineHeight = 18.sp
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -995,6 +995,7 @@ fun ProfileScreen(
|
|||||||
hasAvatar = hasAvatar,
|
hasAvatar = hasAvatar,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
backgroundBlurColorId = backgroundBlurColorId,
|
backgroundBlurColorId = backgroundBlurColorId,
|
||||||
|
onQrCodeClick = onNavigateToMyQr,
|
||||||
onAvatarLongPress = {
|
onAvatarLongPress = {
|
||||||
if (hasAvatar) {
|
if (hasAvatar) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -1014,13 +1015,13 @@ fun ProfileScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📷 CAMERA BUTTON — at boundary between header and content
|
// 📷 + QR FLOATING BUTTONS — at boundary between header and content
|
||||||
// Positioned at bottom-right of header, half overlapping content area
|
// Positioned at bottom-right of header, half overlapping content area
|
||||||
// Fades out when collapsed or when avatar is expanded
|
// Fades out when collapsed or when avatar is expanded
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val cameraButtonSize = 60.dp
|
val cameraButtonSize = 60.dp
|
||||||
val cameraButtonAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
|
val floatingButtonsAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
|
||||||
if (cameraButtonAlpha > 0.01f) {
|
if (floatingButtonsAlpha > 0.01f) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
@@ -1028,24 +1029,31 @@ fun ProfileScreen(
|
|||||||
x = (-16).dp,
|
x = (-16).dp,
|
||||||
y = headerHeight - cameraButtonSize / 2
|
y = headerHeight - cameraButtonSize / 2
|
||||||
)
|
)
|
||||||
.size(cameraButtonSize)
|
.graphicsLayer { alpha = floatingButtonsAlpha }
|
||||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
|
||||||
.shadow(
|
|
||||||
elevation = 4.dp,
|
|
||||||
shape = CircleShape,
|
|
||||||
clip = false
|
|
||||||
)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
|
|
||||||
.clickable { showPhotoPicker = true },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
painter = TelegramIcons.AddPhoto,
|
modifier = Modifier
|
||||||
contentDescription = "Change avatar",
|
.size(cameraButtonSize)
|
||||||
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
.shadow(
|
||||||
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
elevation = 4.dp,
|
||||||
)
|
shape = CircleShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isDarkTheme) Color(0xFF2A2A2A)
|
||||||
|
else Color(0xFF0D8CF4)
|
||||||
|
)
|
||||||
|
.clickable { showPhotoPicker = true },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.AddPhoto,
|
||||||
|
contentDescription = "Change avatar",
|
||||||
|
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
||||||
|
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1103,6 +1111,7 @@ private fun CollapsingProfileHeader(
|
|||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
avatarRepository: AvatarRepository?,
|
avatarRepository: AvatarRepository?,
|
||||||
backgroundBlurColorId: String = "avatar",
|
backgroundBlurColorId: String = "avatar",
|
||||||
|
onQrCodeClick: () -> Unit = {},
|
||||||
onAvatarLongPress: () -> Unit = {}
|
onAvatarLongPress: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
@@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader(
|
|||||||
expanded = showAvatarMenu,
|
expanded = showAvatarMenu,
|
||||||
onDismiss = { onAvatarMenuChange(false) },
|
onDismiss = { onAvatarMenuChange(false) },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
onQrCodeClick = {
|
||||||
|
onAvatarMenuChange(false)
|
||||||
|
onQrCodeClick()
|
||||||
|
},
|
||||||
onSetPhotoClick = {
|
onSetPhotoClick = {
|
||||||
onAvatarMenuChange(false)
|
onAvatarMenuChange(false)
|
||||||
onSetPhotoClick()
|
onSetPhotoClick()
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ fun ThemeScreen(
|
|||||||
var themeRevealToDark by remember { mutableStateOf(false) }
|
var themeRevealToDark by remember { mutableStateOf(false) }
|
||||||
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
||||||
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
|
// Prewarm bitmap on screen appear
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(300)
|
||||||
|
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
@@ -130,7 +137,8 @@ fun ThemeScreen(
|
|||||||
|
|
||||||
val center =
|
val center =
|
||||||
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
|
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
|
||||||
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
|
||||||
|
prewarmedBitmap = null
|
||||||
if (snapshotBitmap == null) {
|
if (snapshotBitmap == null) {
|
||||||
themeMode = targetMode
|
themeMode = targetMode
|
||||||
onThemeModeChange(targetMode)
|
onThemeModeChange(targetMode)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash
|
|||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -64,7 +66,11 @@ fun SplashScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(backgroundColor),
|
.background(backgroundColor)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Glow effect behind logo
|
// Glow effect behind logo
|
||||||
|
|||||||
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
4
app/src/main/res/drawable/ic_calc_background.xml
Normal file
4
app/src/main/res/drawable/ic_calc_background.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FFFFFF"/>
|
||||||
|
</shape>
|
||||||
7
app/src/main/res/drawable/ic_calc_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_calc_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:inset="20%">
|
||||||
|
<bitmap
|
||||||
|
android:src="@drawable/ic_calc_downloaded"
|
||||||
|
android:gravity="fill"/>
|
||||||
|
</inset>
|
||||||
4
app/src/main/res/drawable/ic_notes_background.xml
Normal file
4
app/src/main/res/drawable/ic_notes_background.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FFFFFF"/>
|
||||||
|
</shape>
|
||||||
7
app/src/main/res/drawable/ic_notes_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_notes_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:inset="20%">
|
||||||
|
<bitmap
|
||||||
|
android:src="@drawable/ic_notes_downloaded"
|
||||||
|
android:gravity="fill"/>
|
||||||
|
</inset>
|
||||||
4
app/src/main/res/drawable/ic_weather_background.xml
Normal file
4
app/src/main/res/drawable/ic_weather_background.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FFFFFF"/>
|
||||||
|
</shape>
|
||||||
7
app/src/main/res/drawable/ic_weather_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_weather_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:inset="20%">
|
||||||
|
<bitmap
|
||||||
|
android:src="@drawable/ic_weather_downloaded"
|
||||||
|
android:gravity="fill"/>
|
||||||
|
</inset>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_calc_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_calc_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_notes_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_notes_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_weather_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_weather_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/res/raw/phone_duck.json
Normal file
1
app/src/main/res/raw/phone_duck.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TextSelectionHelperTest {
|
||||||
|
|
||||||
|
private lateinit var helper: TextSelectionHelper
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
helper = TextSelectionHelper()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is not active`() {
|
||||||
|
assertFalse(helper.isActive)
|
||||||
|
assertFalse(helper.isInSelectionMode)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
assertNull(helper.selectedMessageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear resets all state`() {
|
||||||
|
helper.clear()
|
||||||
|
assertFalse(helper.isActive)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
assertNull(helper.selectedMessageId)
|
||||||
|
assertNull(helper.layoutInfo)
|
||||||
|
assertFalse(helper.showToolbar)
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSelectedText returns null when not active`() {
|
||||||
|
assertNull(helper.getSelectedText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateSelectionEnd does not change when not active`() {
|
||||||
|
helper.updateSelectionEnd(5)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateSelectionStart does not change when not active`() {
|
||||||
|
helper.updateSelectionStart(0)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharOffsetFromCoords returns -1 when no layout`() {
|
||||||
|
assertEquals(-1, helper.getCharOffsetFromCoords(100, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectAll does nothing when no layout`() {
|
||||||
|
helper.selectAll()
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moveHandle does nothing when not moving`() {
|
||||||
|
helper.moveHandle(100f, 100f)
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endHandleDrag sets movingHandle to false`() {
|
||||||
|
helper.endHandleDrag()
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.input
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VoiceRecordHelpersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatVoiceRecordTimer formats zero`() {
|
||||||
|
assertEquals("0:00,0", formatVoiceRecordTimer(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatVoiceRecordTimer formats 12300ms`() {
|
||||||
|
assertEquals("0:12,3", formatVoiceRecordTimer(12300L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatVoiceRecordTimer formats 61500ms`() {
|
||||||
|
assertEquals("1:01,5", formatVoiceRecordTimer(61500L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatVoiceRecordTimer handles negative`() {
|
||||||
|
assertEquals("0:00,0", formatVoiceRecordTimer(-100L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressVoiceWaves empty source returns zeros`() {
|
||||||
|
val result = compressVoiceWaves(emptyList(), 5)
|
||||||
|
assertEquals(5, result.size)
|
||||||
|
assertTrue(result.all { it == 0f })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressVoiceWaves same size returns same`() {
|
||||||
|
val source = listOf(0.1f, 0.5f, 0.9f)
|
||||||
|
assertEquals(source, compressVoiceWaves(source, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressVoiceWaves downsamples by max`() {
|
||||||
|
val source = listOf(0.1f, 0.8f, 0.3f, 0.9f, 0.2f, 0.7f)
|
||||||
|
val result = compressVoiceWaves(source, 3)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertEquals(0.8f, result[0], 0.01f)
|
||||||
|
assertEquals(0.9f, result[1], 0.01f)
|
||||||
|
assertEquals(0.7f, result[2], 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressVoiceWaves target zero returns empty`() {
|
||||||
|
assertEquals(emptyList<Float>(), compressVoiceWaves(listOf(1f), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressVoiceWaves upsamples via interpolation`() {
|
||||||
|
val source = listOf(0.0f, 1.0f)
|
||||||
|
val result = compressVoiceWaves(source, 3)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertEquals(0.0f, result[0], 0.01f)
|
||||||
|
assertEquals(0.5f, result[1], 0.01f)
|
||||||
|
assertEquals(1.0f, result[2], 0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user