Compare commits

20 Commits

Author SHA1 Message Date
0d21769399 Доработан UI чатов и звонков (запись ГС, экран звонков, профиль) 2026-04-15 21:27:56 +05:00
060d0cbd12 Чат/звонки/коннект: Telegram-like UX и ряд фиксов 2026-04-15 02:29:08 +05:00
4396611355 Доработан мини-плеер голосовых: интеграция в чат, smooth UI, фикс баг с auto-play при смене скорости 2026-04-14 13:53:01 +05:00
ce7f913de7 fix: Большое количество изменений 2026-04-14 04:19:34 +05:00
cb920b490d Смена иконки приложения — калькулятор, погода, заметки + экран выбора в настройка 2026-04-12 23:59:04 +05:00
b1fc623f5e Выделение текста + фикс ANR при записи ГС 2026-04-12 23:05:55 +05:00
ad08af7f0c Выделение текста — selection mode, handles, toolbar, magnifier 2026-04-12 18:37:38 +05:00
9fe5f35923 fix: посимвольное выделение + magnifier на позиции handle + haptic на каждый символ
Было: word snap при drag handle → нельзя выделить часть слова
Стало: посимвольно при drag (word snap только при первом long press)

Magnifier: показывается на позиции handle (текущий символ),
а не на позиции пальца. По Y — центр строки текста.

Haptic: TEXT_HANDLE_MOVE на каждый символ (не на каждое слово).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:32 +05:00
78925dd61d fix: magnifier правильные координаты + haptic при изменении выделения
Magnifier:
- Конвертация overlay-local → view-local координаты для Magnifier.show()
- Builder: 240×64px, cornerRadius 12, elevation 4, offset -80 (над текстом)

Haptic:
- TEXT_HANDLE_MOVE при каждом изменении selectionStart/selectionEnd
- Как в Telegram: вибрация при перемещении handle по словам

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:12:29 +05:00
1ac3d93f74 fix: правильные координаты text selection — window→overlay-local конвертация
Root cause: overlay Canvas рисует в локальных координатах, но LayoutInfo
возвращает позицию в window coordinates. Разница = position status bar,
toolbar, и parent padding → highlight смещался вниз.

Фикс:
- onGloballyPositioned на overlay Box → знаем overlayWindowX/Y
- Canvas: offsetX/Y = info.windowX - overlayWindowX (window→local)
- getCharOffsetFromCoords: overlay-local → text-local через ту же delta
- Handle positions теперь в overlay-local координатах → drag работает

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:08:12 +05:00
6ad24974e0 feat: magnifier view setup + unit тесты для TextSelectionHelper
- setMagnifierView(view) в ChatDetailScreen через LaunchedEffect
- 9 unit тестов: initial state, clear, getSelectedText, boundary checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:15:50 +05:00
e825a1ef30 feat: добавить floating toolbar (Copy/Select All) и Magnifier (API 28+) для text selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:14:30 +05:00
7fcf1195e1 feat: интегрировать TextSelectionHelper в ChatDetailScreen и MessageBubble
- TextSelectionHelper инстанс в ChatDetailScreen
- TextSelectionOverlay поверх LazyColumn
- Clear selection при scroll и при message selection mode
- onTextLongPress + onViewCreated проброшены через MessageBubble к AppleEmojiText

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:12:16 +05:00
a10482b794 feat: добавить onTextLongPress callback и getLayoutInfo() в AppleEmojiTextView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:25 +05:00
419761e34d feat: добавить TextSelectionOverlay — highlight, handles, drag interaction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:03:35 +05:00
988896c080 feat: добавить TextSelectionHelper — core state, word snap, char offset
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:44:46 +05:00
b57e48fe20 fix: зависание записи ГС — race condition в startVoiceRecording + утечка isVoiceRecordTransitioning
Root cause 1: startVoiceRecording() проверял только isVoiceRecording,
но isVoiceRecording=true ставился через 192ms в scope.launch. При быстром
двойном тапе два MediaRecorder создавались, первый терялся (утечка).
Фикс: добавлен guard на isVoiceRecordTransitioning и voiceRecorder!=null.

Root cause 2: isVoiceRecordTransitioning=true ставился перед scope.launch,
но если launch крашился или composable disposed, transitioning навсегда
оставался true — gesture guard блокировал все записи до перезапуска.
Фикс: try/catch в launch + reset в DisposableEffect.

Root cause 3: DisposableEffect проверял только isVoiceRecording, но не
voiceRecorder!=null — если recorder создан но isVoiceRecording ещё false,
recorder не освобождался при dispose.
Фикс: проверка voiceRecorder!=null в dispose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:17:03 +05:00
5c02ff6fd3 fix: LOCKED panel 1:1 с Telegram — полностью другой layout при lock
Telegram при LOCKED: таймер и dot СКРЫТЫ, вместо них:
- [Delete 44dp] — красная иконка удаления слева
- [Waveform] — заполняет оставшееся место
- Lock→Pause кнопка наверху (отдельный overlay)
- Circle = Send (без blob)

При RECORDING (без изменений):
- [dot][timer] [◀ Slide to cancel] [Circle+Blob]

Реализация: AnimatedContent crossfade между двумя полностью
разными panel layouts. RecordLockedControls больше не используется
в панели — delete в самой панели, pause в LockIcon overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:33:26 +05:00
7630aa6874 fix: LOCKED UI как в Telegram — CANCEL текст вместо ✕, без blob при lock
Telegram LOCKED layout: [timer] [waveform] [CANCEL] [⏸] [Send]

Изменения:
- RecordLockedControls: убрана круглая ✕ кнопка delete
- Вместо неё: текст "CANCEL" синим bold 15sp (как в Telegram)
- Пауза иконка увеличена 12→14dp, фон 15% alpha
- Blob анимация скрыта при LOCKED/PAUSED (Telegram: solid circle)
- Spacing 8→12dp между CANCEL и паузой

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:15:09 +05:00
afebbf6acb fix: slide-to-cancel не работает при LOCKED/PAUSED — как в Telegram
Telegram: при sendButtonVisible=true gesture handler возвращает false,
полностью блокируя горизонтальный свайп. Slide-to-cancel исчезает,
вместо него кнопка Cancel.

Изменения:
- Gesture handler: только RECORDING обрабатывает slide (было RECORDING||LOCKED)
- slideDx/slideDy не обновляются при LOCKED/PAUSED
- При lock: slideDx=0, slideDy=0 — сбрасываем горизонтальное смещение
- AnimatedContent уже переключает SlideToCancel→waveform при LOCKED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:08:33 +05:00
40 changed files with 3606 additions and 764 deletions

View File

@@ -47,10 +47,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -65,6 +62,63 @@
</intent-filter>
</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
android:name=".IncomingCallActivity"
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.AuthFlow
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.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.GroupInfoScreen
import com.rosetta.messenger.ui.chats.GroupSetupScreen
@@ -85,6 +92,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : FragmentActivity() {
@@ -296,16 +304,57 @@ class MainActivity : FragmentActivity() {
startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account ->
startCreateAccountFlow = false
currentAccount = account
cacheSessionAccount(account)
val normalizedAccount =
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
// Save as last logged account
account?.let {
normalizedAccount?.let {
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
scope.launch {
normalizedAccount?.let {
// Синхронно помечаем текущий аккаунт активным в DataStore.
runCatching {
accountManager.setCurrentAccount(it.publicKey)
}
}
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() }
}
@@ -672,6 +721,7 @@ sealed class Screen {
data object CrashLogs : Screen()
data object Biometric : Screen()
data object Appearance : Screen()
data object AppIcon : Screen()
data object QrScanner : Screen()
data object MyQr : Screen()
}
@@ -1031,6 +1081,9 @@ fun MainScreen(
val isAppearanceVisible by remember {
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 isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } }
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
@@ -1437,12 +1490,25 @@ fun MainScreen(
}
},
onToggleTheme = onToggleTheme,
onAppIconClick = { navStack = navStack + Screen.AppIcon },
accountPublicKey = accountPublicKey,
accountName = accountName,
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(
isVisible = isUpdatesVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
@@ -1469,9 +1535,18 @@ fun MainScreen(
}
}.collectAsState(initial = 0)
var chatSelectionActive by remember { mutableStateOf(false) }
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
SwipeBackContainer(
isVisible = selectedUser != null,
onBack = { popChatAndChildren() },
onInterceptSwipeBack = {
if (chatSelectionActive) {
chatClearSelectionRef.value()
true
} else false
},
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked,
@@ -1516,7 +1591,9 @@ fun MainScreen(
avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
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 при параллельной
// расшифровке
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)
init {
@@ -298,17 +302,21 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
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)
val cacheKey = "$password:$encryptedData"
decryptionCache[cacheKey]?.let {
return it
if (cacheKey != null) {
decryptionCache[cacheKey]?.let {
return it
}
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
if (result != null) {
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
// Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей

View File

@@ -30,7 +30,6 @@ data class Message(
val replyToMessageId: String? = null
)
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -599,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
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
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)

View File

@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID =
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)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
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
// ═════════════════════════════════════════════════════════════

View File

@@ -95,7 +95,11 @@ object CallManager {
private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 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 val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -127,6 +131,7 @@ object CallManager {
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
@@ -290,6 +295,18 @@ object CallManager {
)
breadcrumbState("startOutgoingCall")
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
}
@@ -551,6 +568,9 @@ object CallManager {
breadcrumb("SIG: ACCEPT ignored — role=$role")
return
}
// Callee answered before timeout — cancel outgoing ring timer
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys()
@@ -1033,9 +1053,14 @@ object CallManager {
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 {
runCatching {
if (role == CallRole.CALLER) {
if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey,
@@ -1082,6 +1107,8 @@ object CallManager {
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
// Play end call sound, then stop all
if (wasActive) {
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 RECONNECT_INTERVAL = 5000L // 5 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 DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
@@ -182,6 +183,7 @@ class Protocol(
private var lastSuccessfulConnection = 0L
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
private var isConnecting = false // Флаг для защиты от одновременных подключений
private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -385,6 +387,7 @@ class Protocol(
*/
fun connect() {
val currentState = _state.value
val now = System.currentTimeMillis()
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
@@ -403,10 +406,20 @@ class Protocol(
return
}
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
if (isConnecting || currentState == ProtocolState.CONNECTING) {
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
return
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
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
@@ -424,6 +437,7 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true
connectingSinceMs = now
reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
@@ -455,6 +469,7 @@ class Protocol(
// Сбрасываем флаг подключения
isConnecting = false
connectingSinceMs = 0L
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open.
@@ -500,6 +515,7 @@ class Protocol(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
handleDisconnect()
}
@@ -511,6 +527,7 @@ class Protocol(
log(" Reconnect attempts: $reconnectAttempts")
t.printStackTrace()
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
_lastError.value = t.message
handleDisconnect()
}
@@ -801,6 +818,7 @@ class Protocol(
log("🔌 Manual disconnect requested")
isManuallyClosed = true
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
reconnectJob?.cancel() // Отменяем запланированные переподключения
reconnectJob = null
handshakeJob?.cancel()
@@ -823,6 +841,7 @@ class Protocol(
fun reconnectNowIfNeeded(reason: String = "foreground") {
val currentState = _state.value
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
val now = System.currentTimeMillis()
log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
@@ -830,12 +849,22 @@ class Protocol(
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.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
currentState == ProtocolState.CONNECTED ||
(currentState == ProtocolState.CONNECTING && isConnecting)
currentState == ProtocolState.CONNECTED
) {
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.ui.chats.attach.ChatAttachAlert
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.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.*
@@ -324,7 +325,9 @@ fun ChatDetailScreen(
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {}
onOpenCallOverlay: () -> Unit = {},
onSelectionModeChange: (Boolean) -> Unit = {},
registerClearSelection: (() -> Unit) -> Unit = {}
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
@@ -389,11 +392,23 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
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 на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
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
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
@@ -437,11 +452,29 @@ fun ChatDetailScreen(
showEmojiPicker = false
}
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
// 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
val hideKeyboardAndBack: () -> Unit = {
hideInputOverlays()
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 или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey
@@ -611,6 +644,8 @@ fun ChatDetailScreen(
showImageViewer,
showMediaPicker,
showEmojiPicker,
textSelectionHelper.isActive,
textSelectionHelper.movingHandle,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera,
@@ -620,6 +655,8 @@ fun ChatDetailScreen(
showImageViewer ||
showMediaPicker ||
showEmojiPicker ||
textSelectionHelper.isActive ||
textSelectionHelper.movingHandle ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera ||
@@ -838,6 +875,7 @@ fun ChatDetailScreen(
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
{ messageId, canSelect ->
textSelectionHelper.clear()
if (canSelect && !selectedMessages.contains(messageId)) {
selectedMessages = selectedMessages + messageId
}
@@ -886,6 +924,13 @@ fun ChatDetailScreen(
}
}
// 🔤 Сброс текстового выделения при скролле
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress && textSelectionHelper.isActive) {
textSelectionHelper.clear()
}
}
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
val displayReplyMessages =
remember(replyMessages, messages) {
@@ -1325,10 +1370,10 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler {
if (isInChatSearchMode) {
closeInChatSearch()
} else {
hideKeyboardAndBack()
when {
isSelectionMode -> selectedMessages = emptySet()
isInChatSearchMode -> closeInChatSearch()
else -> handleBackWithInputPriority()
}
}
@@ -1843,7 +1888,7 @@ fun ChatDetailScreen(
Box {
IconButton(
onClick =
hideKeyboardAndBack,
handleBackWithInputPriority,
modifier =
Modifier.size(
40.dp
@@ -2289,6 +2334,36 @@ fun ChatDetailScreen(
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
},
containerColor = backgroundColor, // Фон всего чата
@@ -3025,6 +3100,7 @@ fun ChatDetailScreen(
else -> {
LazyColumn(
state = listState,
userScrollEnabled = !textSelectionHelper.movingHandle,
modifier =
Modifier.fillMaxSize()
.nestedScroll(
@@ -3164,6 +3240,8 @@ fun ChatDetailScreen(
MessageBubble(
message =
message,
textSelectionHelper =
textSelectionHelper,
isDarkTheme =
isDarkTheme,
hasWallpaper =
@@ -3644,6 +3722,11 @@ fun ChatDetailScreen(
}
}
}
// 🔤 Text selection overlay
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
helper = textSelectionHelper,
modifier = Modifier.fillMaxSize()
)
}
}
}

View File

@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey,
senderName = fm.senderName,
attachments = fm.attachments
attachments = fm.attachments,
chachaKeyPlainHex = fm.chachaKeyPlain
)
}
_isForwardMode.value = true
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedList.add(ReplyData(
messageId = fwdMessageId,
senderName = senderDisplayName,
text = fwdText,
text = resolveReplyPreviewText(fwdText, fwdAttachments),
isFromMe = fwdIsFromMe,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = realMessageId,
senderName = resolvedSenderName,
text = replyText,
text = resolveReplyPreviewText(replyText, originalAttachments),
isFromMe = isReplyFromMe,
isForwarded = isForwarded,
forwardedFromName = forwardFromDisplay,
@@ -2501,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 отправителя для
* правильного отображения цитаты
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES },
attachments = resolvedAttachments,
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
attachments = resolvedAttachments,
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
_isForwardMode.value = true
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = firstReply.messageId,
senderName = firstReplySenderName,
text = firstReply.text,
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
isFromMe = firstReply.isOutgoing,
isForwarded = isForward,
forwardedFromName =
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) {
put("forwarded", true)
put("senderName", msg.senderName)
if (msg.chachaKeyPlainHex.isNotEmpty()) {
put("chacha_key_plain", msg.chachaKeyPlainHex)
}
}
}
replyJsonArray.put(replyJson)
@@ -5863,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
return
}
val privateKey =
myPrivateKey
?: run {

View File

@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
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.runtime.*
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.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
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.components.AnimatedDotsText
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.AvatarImage
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) }
}
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 {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
@@ -237,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
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_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -297,9 +324,6 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.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 drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -467,23 +491,44 @@ fun ChatsListScreen(
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.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) {
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
val launchStart = System.currentTimeMillis()
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
// Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(accountPublicKey)
val normalizedPublicKey = accountPublicKey.trim()
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d(
"ChatsListScreen",
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
)
val normalizedPrivateKey = accountPrivateKey.trim()
val launchStart = System.currentTimeMillis()
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
// Устанавливаем аккаунт для RecentSearchesManager
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
@@ -581,9 +626,44 @@ fun ChatsListScreen(
LaunchedEffect(accountPublicKey) {
val accountManager = AccountManager(context)
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
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -602,7 +682,7 @@ fun ChatsListScreen(
val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet())
// Back: drawer → закрыть, selection → сбросить
@@ -633,6 +713,31 @@ fun ChatsListScreen(
val topLevelRequestsCount = topLevelChatsState.requestsCount
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
/*
if (showDevConsole) {
@@ -783,10 +888,6 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
ModalNavigationDrawer(
drawerState = drawerState,
@@ -869,16 +970,16 @@ fun ChatsListScreen(
) {
AvatarImage(
publicKey =
accountPublicKey,
effectiveCurrentPublicKey,
avatarRepository =
avatarRepository,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =
accountName
sidebarAccountName
.ifEmpty {
accountUsername
sidebarAccountUsername
}
)
}
@@ -930,13 +1031,13 @@ fun ChatsListScreen(
) {
Column(modifier = Modifier.weight(1f)) {
// Display name
if (accountName.isNotEmpty()) {
if (sidebarAccountName.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = accountName,
text = sidebarAccountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
@@ -955,10 +1056,10 @@ fun ChatsListScreen(
}
// Username
if (accountUsername.isNotEmpty()) {
if (sidebarAccountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@$accountUsername",
text = "@$sidebarAccountUsername",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
@@ -999,7 +1100,9 @@ fun ChatsListScreen(
Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list (max 5 like Telegram sidebar behavior)
allAccounts.take(5).forEach { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val isCurrentAccount =
account.publicKey ==
effectiveCurrentPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
@@ -1279,6 +1382,9 @@ fun ChatsListScreen(
}
)
// Keep distance from footer divider so it never overlays Settings.
Spacer(modifier = Modifier.height(8.dp))
}
// ═══════════════════════════════════════════════════════════
@@ -1287,9 +1393,12 @@ fun ChatsListScreen(
Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
.windowInsetsPadding(
WindowInsets
.navigationBars
.only(
WindowInsetsSides.Bottom
)
)
) {
// Telegram-style update banner
@@ -2130,6 +2239,50 @@ fun ChatsListScreen(
callUiState.phase != CallPhase.INCOMING
}
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 для
// консистентности
// 📌 Порядок по времени готовится в ViewModel.
@@ -2332,9 +2485,7 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
stickyTopInset
)
.then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
@@ -2572,6 +2723,18 @@ fun ChatsListScreen(
}
}
}
val isVoicePlaybackActive by
remember(
dialog.opponentKey,
playingVoiceDialogKey
) {
derivedStateOf {
isVoicePlayingForDialog(
dialog.opponentKey,
playingVoiceDialogKey
)
}
}
val isSelectedDialog =
selectedChatKeys
.contains(
@@ -2613,6 +2776,8 @@ fun ChatsListScreen(
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
isVoicePlaybackActive =
isVoicePlaybackActive,
isBlocked =
isBlocked,
isSavedMessages =
@@ -2746,14 +2911,51 @@ fun ChatsListScreen(
}
}
}
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
if (showStickyCallBanner || showVoiceMiniPlayer) {
Column(
modifier =
Modifier.fillMaxWidth()
.align(
Alignment.TopCenter
)
) {
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()
}
)
}
}
}
}
}
@@ -3722,6 +3924,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
@@ -4125,6 +4328,7 @@ fun SwipeableDialogItem(
isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
isVoicePlaybackActive = isVoicePlaybackActive,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
@@ -4144,6 +4348,7 @@ fun DialogItemContent(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
@@ -4278,12 +4483,12 @@ fun DialogItemContent(
// Name and last message
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppleEmojiText(
@@ -4293,7 +4498,8 @@ fun DialogItemContent(
color = textColor,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
enableLinks = false,
minHeightMultiplier = 1f
)
if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp))
@@ -4301,7 +4507,7 @@ fun DialogItemContent(
imageVector = TablerIcons.Users,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp)
modifier = Modifier.size(14.dp)
)
}
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -4310,7 +4516,7 @@ fun DialogItemContent(
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,
size = 16,
modifier = Modifier.offset(y = (-2).dp),
modifier = Modifier,
isDarkTheme = isDarkTheme,
badgeTint = PrimaryBlue
)
@@ -4337,6 +4543,7 @@ fun DialogItemContent(
}
Row(
modifier = Modifier.heightIn(min = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
@@ -4467,7 +4674,7 @@ fun DialogItemContent(
0.6f
),
modifier =
Modifier.size(14.dp)
Modifier.size(16.dp)
)
Spacer(
modifier =
@@ -4487,9 +4694,11 @@ fun DialogItemContent(
Text(
text = formattedTime,
fontSize = 13.sp,
lineHeight = 13.sp,
color =
if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor
else secondaryTextColor,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
@@ -4506,18 +4715,35 @@ fun DialogItemContent(
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
contentAlignment = Alignment.CenterStart
) {
val subtitleMode =
remember(
isVoicePlaybackActive,
isTyping,
dialog.draftText
) {
when {
isVoicePlaybackActive -> "voice"
isTyping -> "typing"
!dialog.draftText.isNullOrEmpty() -> "draft"
else -> "message"
}
}
Crossfade(
targetState = isTyping,
targetState = subtitleMode,
animationSpec = tween(150),
label = "chatSubtitle"
) { showTyping ->
if (showTyping) {
) { mode ->
if (mode == "voice") {
VoicePlaybackIndicatorSmall(
isDarkTheme = isDarkTheme
)
} else if (mode == "typing") {
TypingIndicatorSmall(
isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey
)
} else if (!dialog.draftText.isNullOrEmpty()) {
} else if (mode == "draft") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Draft: ",
@@ -4527,7 +4753,7 @@ fun DialogItemContent(
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
text = dialog.draftText.orEmpty(),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
@@ -4868,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
private fun SwipeBackContainer(
onBack: () -> Unit,
@@ -5467,7 +5854,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5527,7 +5914,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5561,7 +5948,7 @@ fun DrawerMenuItemEnhanced(
fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
Divider(
modifier = Modifier.padding(horizontal = 20.dp),
modifier = Modifier.fillMaxWidth(),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
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.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
private var loadingFailSafeJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
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()
private val loadingFailSafeTimeoutMs = 4500L
private val TAG = "ChatsListVM"
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:")
}
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(
val senderPrefix: String,
val senderKey: String
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) {
val resolvedPrivateKey =
when {
privateKey.isNotBlank() -> privateKey
currentAccount == publicKey -> currentPrivateKey.orEmpty()
else -> ""
}
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
}
loadingFailSafeJob?.cancel()
return
}
// 🔥 Показываем skeleton пока данные грузятся
_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 при смене аккаунта
requestedUserInfoKeys.clear()
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
currentPrivateKey = privateKey
currentPrivateKey = resolvedPrivateKey
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
if (resolvedPrivateKey.isNotEmpty()) {
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
}
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.filterNotNull()
.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 ->
_dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
}
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
}
}
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
} // 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) {
if (opponentKeys.isEmpty()) return
if (privateKey.isBlank()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys =

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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
color = sectionColor,
shape = RoundedCornerShape(12.dp)
modifier = Modifier.fillMaxWidth(),
color = sectionColor
) {
Column {
// Add Members
// Add Members — flat Telegram style, edge-to-edge, white text
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
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 = "Add Members",
color = primaryText,
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(groupMenuTrailingIconSize)
)
}
Divider(
color = borderColor,
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(
modifier = Modifier
.fillMaxWidth()
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
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 = "Encryption Key",
color = primaryText,

View File

@@ -573,23 +573,23 @@ private fun ChatsTabContent(
}
}
// ─── Recent header (always show with Clear All) ───
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 14.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Recent",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue
)
if (recentUsers.isNotEmpty()) {
// ─── Recent header (only when there are recents) ───
if (recentUsers.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 14.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Recent",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue
)
Text(
"Clear All",
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
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.filled.Call
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.graphics.Color
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.unit.dp
import androidx.compose.ui.unit.sp
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.RosettaDatabase
import com.rosetta.messenger.network.SearchUser
@@ -106,16 +111,21 @@ fun CallsHistoryScreen(
LazyColumn(
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()) {
item(key = "empty_calls") {
EmptyCallsState(
isDarkTheme = isDarkTheme,
title = "No calls yet",
subtitle = "Your call history will appear here",
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
)
Column(
modifier = Modifier.fillParentMaxSize(),
verticalArrangement = Arrangement.Center
) {
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 {
items(items, key = { it.messageId }) { item ->
@@ -273,39 +283,63 @@ private fun EmptyCallsState(
subtitle: String,
modifier: Modifier = Modifier
) {
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
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(
modifier = modifier.padding(horizontal = 32.dp),
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardColor, RoundedCornerShape(28.dp))
.padding(horizontal = 20.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(34.dp)
if (lottieComposition != null) {
LottieAnimation(
composition = lottieComposition,
progress = { lottieProgress },
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

@@ -7,12 +7,14 @@ import android.graphics.Matrix
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.util.Base64
import android.util.LruCache
import android.webkit.MimeTypeMap
import android.widget.VideoView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
@@ -41,6 +43,8 @@ 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.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -84,6 +88,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
@@ -91,7 +96,10 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.min
import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
@@ -141,37 +149,72 @@ private fun decodeBase64Payload(data: String): ByteArray? {
private fun decodeHexPayload(data: String): ByteArray? {
val raw = data.trim().removePrefix("0x")
if (raw.isBlank() || raw.length % 2 != 0) return null
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null
return runCatching {
ByteArray(raw.length / 2) { index ->
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte()
fun nibble(ch: Char): Int =
when (ch) {
in '0'..'9' -> ch.code - '0'.code
in 'a'..'f' -> ch.code - 'a'.code + 10
in 'A'..'F' -> ch.code - 'A'.code + 10
else -> -1
}
}.getOrNull()
val out = ByteArray(raw.length / 2)
var outIndex = 0
var index = 0
while (index < raw.length) {
val hi = nibble(raw[index])
val lo = nibble(raw[index + 1])
if (hi < 0 || lo < 0) return null
out[outIndex++] = ((hi shl 4) or lo).toByte()
index += 2
}
return out
}
private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data)
}
private object VoicePlaybackCoordinator {
object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f)
private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null
private var progressJob: Job? = null
private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _playingDialogKey = MutableStateFlow<String?>(null)
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
private val _positionMs = MutableStateFlow(0)
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
private val _durationMs = MutableStateFlow(0)
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
private val _playbackSpeed = MutableStateFlow(1f)
val playbackSpeed: StateFlow<Float> = _playbackSpeed.asStateFlow()
private val _playingSenderLabel = MutableStateFlow("")
val playingSenderLabel: StateFlow<String> = _playingSenderLabel.asStateFlow()
private val _playingTimeLabel = MutableStateFlow("")
val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) {
fun toggle(
attachmentId: String,
sourceFile: File,
dialogKey: String = "",
senderLabel: String = "",
playedAtLabel: String = "",
onError: (String) -> Unit = {}
) {
if (!sourceFile.exists()) {
onError("Voice file is missing")
return
}
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
stop()
if (currentAttachmentId == attachmentId && player != null) {
if (_isPlaying.value) {
pause()
} else {
resume(onError = onError)
}
return
}
@@ -187,22 +230,18 @@ private object VoicePlaybackCoordinator {
mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() }
mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer)
mediaPlayer.start()
player = mediaPlayer
currentAttachmentId = attachmentId
_playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
_playingSenderLabel.value = senderLabel.trim()
_playingTimeLabel.value = playedAtLabel.trim()
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
progressJob?.cancel()
progressJob =
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player
if (active == null || !active.isPlaying) break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
delay(120)
}
}
_isPlaying.value = true
startProgressUpdates(attachmentId)
} catch (e: Exception) {
runCatching { mediaPlayer.release() }
stop()
@@ -210,6 +249,85 @@ private object VoicePlaybackCoordinator {
}
}
fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
if (player == null || currentAttachmentId.isNullOrBlank()) return
if (_isPlaying.value) {
pause()
} else {
resume(onError = onError)
}
}
fun pause() {
val active = player ?: return
runCatching {
if (active.isPlaying) active.pause()
}
_isPlaying.value = false
progressJob?.cancel()
progressJob = null
_positionMs.value = active.currentPosition.coerceAtLeast(0)
}
fun resume(onError: (String) -> Unit = {}) {
val active = player ?: return
val attachmentId = currentAttachmentId
if (attachmentId.isNullOrBlank()) return
try {
applyPlaybackSpeed(active)
active.start()
_durationMs.value = active.duration.coerceAtLeast(0)
_positionMs.value = active.currentPosition.coerceAtLeast(0)
_isPlaying.value = true
startProgressUpdates(attachmentId)
} catch (e: Exception) {
stop()
onError(e.message ?: "Playback failed")
}
}
fun cycleSpeed() {
val current = _playbackSpeed.value
val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
setPlaybackSpeed(next)
}
private fun setPlaybackSpeed(speed: Float) {
val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
_playbackSpeed.value = normalized
// Only apply to the player if it's currently playing — otherwise setting
// playbackParams auto-resumes a paused MediaPlayer (Android quirk).
// The new speed will be applied on the next resume() call.
if (_isPlaying.value) {
player?.let { applyPlaybackSpeed(it) }
}
}
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
runCatching {
val current = mediaPlayer.playbackParams
mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
}
}
private fun startProgressUpdates(attachmentId: String) {
progressJob?.cancel()
progressJob =
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player ?: break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
_durationMs.value = active.duration.coerceAtLeast(0)
if (!active.isPlaying) break
delay(120)
}
_isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
}
}
fun stop() {
val active = player
player = null
@@ -217,8 +335,12 @@ private object VoicePlaybackCoordinator {
progressJob?.cancel()
progressJob = null
_playingAttachmentId.value = null
_playingDialogKey.value = null
_playingSenderLabel.value = ""
_playingTimeLabel.value = ""
_positionMs.value = 0
_durationMs.value = 0
_isPlaying.value = false
if (active != null) {
runCatching {
if (active.isPlaying) active.stop()
@@ -593,6 +715,7 @@ fun MessageAttachments(
isOutgoing: Boolean,
isDarkTheme: Boolean,
senderPublicKey: String,
senderDisplayName: String = "",
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
timestamp: java.util.Date,
@@ -683,6 +806,8 @@ fun MessageAttachments(
chachaKeyPlainHex = chachaKeyPlainHex,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
senderDisplayName = senderDisplayName,
dialogPublicKey = dialogPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
@@ -1906,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String {
return "$minutes:$rem"
}
private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
val normalized = kotlin.math.round(speed * 10f) / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x"
} else {
"${normalized}x"
}
}
@Composable
private fun VoicePlaybackButtonBlob(
level: Float,
isOutgoing: Boolean,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val clampedLevel = level.coerceIn(0f, 1f)
val animatedLevel by animateFloatAsState(
targetValue = clampedLevel,
animationSpec = tween(durationMillis = 140),
label = "voice_blob_level"
)
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_blob_pulse"
)
val blobColor =
if (isOutgoing) {
Color.White
} else if (isDarkTheme) {
Color(0xFF5DB8FF)
} else {
PrimaryBlue
}
Canvas(modifier = modifier) {
val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
val buttonRadius = 20.dp.toPx() // Play button is 40dp.
val amp = animatedLevel.coerceIn(0f, 1f)
// Telegram-like: soft concentric glow, centered, no geometry distortion.
val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()
drawCircle(
color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
radius = r1,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
radius = r2,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
radius = r3,
center = center
)
}
}
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
@@ -2022,9 +2217,10 @@ private fun ensureVoiceAudioFile(
attachmentId: String,
payload: String
): File? {
val bytes = decodeVoicePayload(payload) ?: return null
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
val file = File(directory, "$attachmentId.webm")
if (file.exists() && file.length() > 0L) return file
val bytes = decodeVoicePayload(payload) ?: return null
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
return file
}
@@ -2036,6 +2232,8 @@ private fun VoiceAttachment(
chachaKeyPlainHex: String,
privateKey: String,
senderPublicKey: String,
senderDisplayName: String,
dialogPublicKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
@@ -2043,10 +2241,21 @@ private fun VoiceAttachment(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
val isPlaying = playingAttachmentId == attachment.id
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val isActiveTrack = activeAttachmentId == attachment.id
val playbackPositionMs by
(if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0))
.collectAsState(initial = 0)
val playbackDurationMs by
(if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0))
.collectAsState(initial = 0)
val playbackIsPlaying by
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
.collectAsState(initial = false)
val playbackSpeed by
(if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
.collectAsState(initial = 1f)
val isPlaying = isActiveTrack && playbackIsPlaying
val (previewDurationSecRaw, previewWavesRaw) =
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
@@ -2060,12 +2269,19 @@ private fun VoiceAttachment(
var payload by
remember(attachment.id, attachment.blob) {
mutableStateOf(attachment.blob.trim())
mutableStateOf(attachment.blob)
}
val cachedAudioPath =
remember(attachment.id) {
val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm")
file.takeIf { it.exists() && it.length() > 0L }?.absolutePath
}
var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) }
var downloadStatus by
remember(attachment.id, attachment.blob, attachment.transportTag) {
mutableStateOf(
when {
cachedAudioPath != null -> DownloadStatus.DOWNLOADED
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
else -> DownloadStatus.ERROR
@@ -2073,52 +2289,51 @@ private fun VoiceAttachment(
)
}
var errorText by remember { mutableStateOf("") }
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
val effectiveDurationSec =
remember(isPlaying, playbackDurationMs, previewDurationSec) {
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
}
val progress =
if (isPlaying && playbackDurationMs > 0) {
if (isActiveTrack && playbackDurationMs > 0) {
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val liveWaveLevel =
remember(isPlaying, progress, waves) {
if (!isPlaying || waves.isEmpty()) {
0f
} else {
val maxIndex = waves.lastIndex.coerceAtLeast(0)
val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
waves[sampleIndex].coerceIn(0f, 1f)
}
}
val timeText =
if (isPlaying && playbackDurationMs > 0) {
if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
"-${formatVoiceDuration(leftSec)}"
formatVoiceDuration(leftSec)
} else {
formatVoiceDuration(effectiveDurationSec)
}
LaunchedEffect(payload, attachment.id) {
if (payload.isBlank()) return@LaunchedEffect
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
if (downloadStatus != DownloadStatus.DOWNLOADING &&
downloadStatus != DownloadStatus.DECRYPTING
) {
downloadStatus = DownloadStatus.DOWNLOADED
val playbackSenderLabel =
remember(isOutgoing, senderDisplayName) {
val senderName = senderDisplayName.trim()
when {
isOutgoing -> "You"
senderName.isNotBlank() -> senderName
else -> "Voice"
}
}
if (errorText.isNotBlank()) errorText = ""
} else {
audioFilePath = null
downloadStatus = DownloadStatus.ERROR
if (errorText.isBlank()) errorText = "Cannot decode voice"
}
}
DisposableEffect(attachment.id) {
onDispose {
if (playingAttachmentId == attachment.id) {
VoicePlaybackCoordinator.stop()
val playbackTimeLabel =
remember(timestamp.time) {
runCatching {
SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
}
.getOrDefault("")
}
}
}
val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) {
@@ -2143,17 +2358,30 @@ private fun VoiceAttachment(
errorText = "Failed to decrypt"
return@launch
}
downloadStatus = DownloadStatus.DECRYPTING
val prepared =
withContext(Dispatchers.IO) {
ensureVoiceAudioFile(context, attachment.id, decrypted)
}
if (prepared == null) {
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice"
return@launch
}
audioFilePath = prepared.absolutePath
val saved =
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
publicKey = senderPublicKey,
privateKey = privateKey
)
}
.getOrDefault(false)
withContext(Dispatchers.IO) {
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
publicKey = senderPublicKey,
privateKey = privateKey
)
}
.getOrDefault(false)
}
payload = decrypted
if (!saved) {
// Не блокируем UI, но оставляем маркер в логе.
@@ -2171,22 +2399,42 @@ private fun VoiceAttachment(
val file = audioFilePath?.let { File(it) }
if (file == null || !file.exists()) {
if (payload.isNotBlank()) {
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
scope.launch {
downloadStatus = DownloadStatus.DECRYPTING
errorText = ""
val prepared =
withContext(Dispatchers.IO) {
ensureVoiceAudioFile(context, attachment.id, payload)
}
if (prepared != null) {
audioFilePath = prepared.absolutePath
downloadStatus = DownloadStatus.DOWNLOADED
VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = prepared,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
} else {
downloadStatus = DownloadStatus.ERROR
errorText = message
errorText = "Cannot decode voice"
}
} else {
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice"
}
} else {
triggerDownload()
}
} else {
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = file,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
@@ -2228,12 +2476,29 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
modifier = Modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
VoicePlaybackButtonBlob(
level = liveWaveLevel,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
modifier =
Modifier.requiredSize(64.dp).graphicsLayer {
// Blob lives strictly behind the button; keep button geometry untouched.
alpha = 0.96f
compositingStrategy = CompositingStrategy.Offscreen
}
)
}
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING
) {
@@ -2271,6 +2536,7 @@ private fun VoiceAttachment(
}
}
}
}
}
Spacer(modifier = Modifier.width(10.dp))
@@ -2323,6 +2589,38 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isActiveTrack) {
val speedChipBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF31435A)
} else {
Color(0xFFDCEBFD)
}
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(speedChipBackground)
.clickable(
interactionSource =
remember { MutableInteractionSource() },
indication = null
) {
VoicePlaybackCoordinator.cycleSpeed()
}
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = speedChipTextColor
)
}
}
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,

View File

@@ -320,6 +320,7 @@ fun TypingIndicator(
@Composable
fun MessageBubble(
message: ChatMessage,
textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
isDarkTheme: Boolean,
hasWallpaper: Boolean = false,
isSystemSafeChat: Boolean = false,
@@ -354,6 +355,16 @@ fun MessageBubble(
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
val isTextSelectionOnThisMessage =
remember(
textSelectionHelper?.isInSelectionMode,
textSelectionHelper?.selectedMessageId,
message.id
) {
textSelectionHelper?.isInSelectionMode == true &&
textSelectionHelper.selectedMessageId == message.id
}
// Swipe-to-reply state
val hapticFeedback = LocalHapticFeedback.current
var swipeOffset by remember { mutableStateOf(0f) }
@@ -373,7 +384,7 @@ fun MessageBubble(
// Selection animations
val selectionAlpha by
animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f,
targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
animationSpec = tween(150),
label = "selectionAlpha"
)
@@ -400,6 +411,10 @@ fun MessageBubble(
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
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 textClickHandler: (() -> Unit)? = onClick
val mentionClickHandler: ((String) -> Unit)? =
@@ -475,8 +490,9 @@ fun MessageBubble(
Box(
modifier =
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
if (isSystemSafeChat) return@pointerInput
if (textSelectionHelper?.isActive == true) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со
// скроллом
@@ -552,7 +568,8 @@ fun MessageBubble(
val selectionBackgroundColor by
animateColorAsState(
targetValue =
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
if (isSelected && !isTextSelectionOnThisMessage)
PrimaryBlue.copy(alpha = 0.15f)
else Color.Transparent,
animationSpec = tween(200),
label = "selectionBg"
@@ -718,6 +735,8 @@ fun MessageBubble(
message.attachments.all {
it.type == AttachmentType.CALL
}
val hasVoiceAttachment =
message.attachments.any { it.type == AttachmentType.VOICE }
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
@@ -857,6 +876,21 @@ fun MessageBubble(
if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
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 {
Modifier.clip(bubbleShape)
.then(
@@ -998,6 +1032,7 @@ fun MessageBubble(
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey,
senderDisplayName = senderName,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
timestamp = message.timestamp,
@@ -1066,7 +1101,32 @@ fun MessageBubble(
onClick =
textClickHandler,
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 = {
@@ -1157,12 +1217,22 @@ fun MessageBubble(
suppressBubbleTapFromSpan,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
)
onLongClick, // 🔥 Long press для selection
onViewCreated = { textViewRef = it },
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { 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
)
},
timeContent = {
Row(
@@ -1261,11 +1331,32 @@ fun MessageBubble(
suppressBubbleTapFromSpan,
onClick = textClickHandler,
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 = {
@@ -2322,6 +2413,8 @@ fun ReplyBubble(
)
} else if (!hasImage) {
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.CALL } -> "Call"
else -> "..."
@@ -3560,6 +3653,7 @@ fun ProfilePhotoMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
onQrCodeClick: (() -> Unit)? = null,
onSetPhotoClick: () -> Unit,
onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false
@@ -3589,6 +3683,16 @@ fun ProfilePhotoMenu(
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(
icon = TelegramIcons.AddPhoto,
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,
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в 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
) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
@@ -601,6 +605,10 @@ fun AppleEmojiText(
enableMentionHighlight(enableMentions)
setOnMentionClickListener(onMentionClick)
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.
val canUseTextViewClick = !enableLinks
setOnClickListener(
@@ -634,6 +642,9 @@ fun AppleEmojiText(
view.enableMentionHighlight(enableMentions)
view.setOnMentionClickListener(onMentionClick)
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.
val canUseTextViewClick = !enableLinks
view.setOnClickListener(
@@ -695,13 +706,23 @@ class AppleEmojiTextView @JvmOverloads constructor(
// 🔥 Long press callback для selection в MessageBubble
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 suppressPerformClickOnce: Boolean = false
private var selectionDragActive: Boolean = false
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
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()
}
}
@@ -721,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
MotionEvent.ACTION_DOWN -> {
downOnClickableSpan = isTouchOnClickableSpan(event)
suppressPerformClickOnce = downOnClickableSpan
selectionDragActive = false
if (downOnClickableSpan) {
clickableSpanPressStartCallback?.invoke()
parent?.requestDisallowInterceptTouchEvent(true)
}
}
MotionEvent.ACTION_MOVE -> {
if (selectionDragActive) {
onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
return true
}
}
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
if (selectionDragActive) {
selectionDragActive = false
onSelectionDragEnd?.invoke()
downOnClickableSpan = false
parent?.requestDisallowInterceptTouchEvent(false)
return true
}
downOnClickableSpan = false
parent?.requestDisallowInterceptTouchEvent(false)
}
}
// Позволяем GestureDetector обработать событие (для long press)
gestureDetector.onTouchEvent(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) {
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
val processMentions = mentionsEnabled && !isLargeText

View File

@@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.findViewTreeLifecycleOwner
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -136,6 +139,8 @@ fun SwipeBackContainer(
propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
// Return true to cancel the swipe — screen bounces back and onBack is NOT called.
onInterceptSwipeBack: () -> Boolean = { false },
content: @Composable () -> Unit
) {
// 🚀 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
val alphaAnimatable = remember { Animatable(0f) }
// Drag state - direct update without animation
// Drag state
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
@@ -177,6 +182,7 @@ fun SwipeBackContainer(
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val lifecycleOwner = view.findViewTreeLifecycleOwner()
val dismissKeyboard: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
@@ -187,21 +193,16 @@ fun SwipeBackContainer(
focusManager.clearFocus(force = true)
}
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
val enterOffset =
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value
} else {
0f
}
val currentOffset = baseOffset + enterOffset
fun computeCurrentOffset(): Float {
val base = if (isDragging) dragOffset else offsetAnimatable.value
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value
} else 0f
return base + enter
}
// Current alpha: use animatable during fade animations, otherwise 1
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 sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
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
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
// 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
val currentOffset = computeCurrentOffset()
val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
Box(
modifier =
Modifier.fillMaxSize().graphicsLayer {
@@ -346,13 +386,15 @@ fun SwipeBackContainer(
var totalDragY = 0f
var passedSlop = false
var keyboardHiddenForGesture = false
var resetOnFinally = true
// deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off.
// deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior).
// Post-claim: always Initial to block children.
while (true) {
try {
while (true) {
val pass =
if (startedSwipe || !deferToChildren)
PointerEventPass.Initial
@@ -365,6 +407,7 @@ fun SwipeBackContainer(
?: break
if (change.changedToUpIgnoreConsumed()) {
resetOnFinally = false
break
}
@@ -443,6 +486,13 @@ fun SwipeBackContainer(
)
change.consume()
}
}
} finally {
// Сбрасываем только при отмене/прерывании жеста.
// При обычном UP сброс делаем позже, чтобы не было рывка.
if (resetOnFinally && isDragging) {
forceResetSwipeState()
}
}
// Handle drag end
@@ -475,6 +525,32 @@ fun SwipeBackContainer(
)
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(
targetValue = screenWidthPx,
animationSpec =

View File

@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
val scope = rememberCoroutineScope()
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
// Auto-switch to matching theme group when app theme changes
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)
}
}
// Local dark/light state — independent from the global app theme
var localIsDark by remember { mutableStateOf(isDarkTheme) }
val theme = qrThemes[selectedThemeIndex]
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
Surface(
modifier = Modifier.fillMaxWidth(),
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
) {
Column(
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
) {
IconButton(onClick = onBack) {
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))
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))
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
// Snapshot → toggle theme → circular reveal
// Snapshot → toggle LOCAL theme → circular reveal
// Does NOT toggle the global app theme
val now = System.currentTimeMillis()
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
lastRevealTime = now
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
revealActive = true
revealCenter = themeButtonPos
revealSnapshot = snapshot.asImageBitmap()
// Switch to matching wallpaper in new theme
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
selectedThemeIndex = newIndex
onToggleTheme()
localIsDark = !localIsDark
scope.launch {
try {
revealRadius.snapTo(0f)
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
revealActive = false
}
}
} else {
// drawToBitmap failed — skip
}
}
// else: cooldown active — ignore tap
},
modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
}
) {
Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
contentDescription = "Toggle theme",
tint = if (isDarkTheme) Color.White else Color.Black
tint = if (localIsDark) Color.White else Color.Black
)
}
}
@@ -360,7 +349,7 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.height(12.dp))
// Wallpaper selector — show current theme's wallpapers
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
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))
}
}

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,
onBlurColorChange: (String) -> Unit,
onToggleTheme: () -> Unit = {},
onAppIconClick: () -> Unit = {},
accountPublicKey: String = "",
accountName: String = "",
avatarRepository: AvatarRepository? = null
@@ -282,6 +283,49 @@ fun AppearanceScreen(
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))
}
}

View File

@@ -995,6 +995,7 @@ fun ProfileScreen(
hasAvatar = hasAvatar,
avatarRepository = avatarRepository,
backgroundBlurColorId = backgroundBlurColorId,
onQrCodeClick = onNavigateToMyQr,
onAvatarLongPress = {
if (hasAvatar) {
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
// Fades out when collapsed or when avatar is expanded
// ═══════════════════════════════════════════════════════════
val cameraButtonSize = 60.dp
val cameraButtonAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
if (cameraButtonAlpha > 0.01f) {
val floatingButtonsAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
if (floatingButtonsAlpha > 0.01f) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -1028,24 +1029,31 @@ fun ProfileScreen(
x = (-16).dp,
y = headerHeight - cameraButtonSize / 2
)
.size(cameraButtonSize)
.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
.graphicsLayer { alpha = floatingButtonsAlpha }
) {
Icon(
painter = TelegramIcons.AddPhoto,
contentDescription = "Change avatar",
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
modifier = Modifier.size(26.dp).offset(x = 2.dp)
)
Box(
modifier = Modifier
.size(cameraButtonSize)
.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(
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,
avatarRepository: AvatarRepository?,
backgroundBlurColorId: String = "avatar",
onQrCodeClick: () -> Unit = {},
onAvatarLongPress: () -> Unit = {}
) {
@Suppress("UNUSED_VARIABLE")
@@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onQrCodeClick = {
onAvatarMenuChange(false)
onQrCodeClick()
},
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()

View File

@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
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.shape.CircleShape
import androidx.compose.runtime.*
@@ -64,7 +66,11 @@ fun SplashScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor),
.background(backgroundColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { },
contentAlignment = Alignment.Center
) {
// 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)
}
}