Релиз 1.5.1: merge dev в master и обновление ReleaseNotes
Some checks failed
Android Kernel Build / build (push) Has been cancelled

This commit is contained in:
2026-04-15 21:36:59 +05:00
46 changed files with 6721 additions and 491 deletions

View File

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

View File

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

View File

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

View File

@@ -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% самых старых записей

View File

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

View File

@@ -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
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

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

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

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

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

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

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

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

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

View 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_weather_background"/>
<foreground android:drawable="@drawable/ic_weather_foreground"/>
</adaptive-icon>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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