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

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

View File

@@ -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)
@@ -1853,7 +1858,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1910,7 +1915,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1974,7 +1979,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -2039,4 +2044,26 @@ class MessageRepository private constructor(private val context: Context) {
}
return jsonArray.toString()
}
/**
* Desktop parity for group attachment blobs:
* old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
*/
private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
val rawAttempt = runCatching {
CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
}.getOrNull()
if (rawAttempt != null) return rawAttempt
val hexKey =
groupKey.toByteArray(Charsets.ISO_8859_1)
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
if (hexKey == groupKey) return null
return runCatching {
CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
}.getOrNull()
}
}

View File

@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID =
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

@@ -17,12 +17,22 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- Исправлена расшифровка фото в группах (совместимость с Desktop v1.2.1)
- Исправлен статус доставки: галочки больше не откатываются на часики
- Исправлен просмотр фото из медиа-галереи профиля
- Зашифрованные ключи больше не отображаются как подпись к фото
- Анимация удаления сообщений (плавное сжатие + fade)
- Фильтрация пустых push-уведомлений
- Полностью переработан UX записи голосовых: удержание для записи, отправка по отпусканию, Slide to cancel
- Пересобрана панель записи ГС в Telegram-style с новым layout, волнами и анимациями
- Добавлена и доработана анимация удаления ГС (корзина), устранены рывки и визуальные артефакты
- Исправлены зависания/ANR при записи и отмене голосовых (race-condition, stuck-состояния, watchdog-сценарии)
- Исправлены скачки и наложения input-панели во время записи (включая Type message/overlay конфликты)
- Добавлены улучшения плеера голосовых: мини-плеер, интеграция в чат, корректная работа скоростей
- В чат-листе улучшено отображение и поведение активного воспроизведения голосовых
- Добавлена и отшлифована система выделения текста: handles, magnifier, toolbar (Copy/Select All), haptic
- Исправлены координаты и стабильность выделения текста в сложных сценариях
- Исправлена обработка reply в группах с Desktop (fallback на hex-ключ для reply blob)
- Оптимизированы тяжелые UI-сценарии: prewarm для circular reveal, ускорена анимация онбординга
- Улучшены миниатюры медиа через BlurHash и стабильность загрузки вложений
- Доработан экран звонков и related UI (включая пустой экран с Lottie-анимацией)
- Доработаны элементы профиля и сайдбара (включая обновления аккаунт-блока и действий)
- Добавлена смена иконки приложения (калькулятор, погода, заметки) через настройки
- Выполнен большой пакет фиксов по чатам/звонкам/коннекту и визуальному паритету с Telegram
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -9,6 +9,8 @@ enum class AttachmentType(val value: Int) {
FILE(2), // Файл
AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
VOICE(5), // Голосовое сообщение
VIDEO_CIRCLE(6), // Видео-кружок (video note)
UNKNOWN(-1); // Неизвестный тип
companion object {

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, // Фон всего чата
@@ -2679,6 +2754,20 @@ fun ChatDetailScreen(
isSendingMessage = false
}
},
onSendVoiceMessage = { voiceHex, durationSec, waves ->
isSendingMessage = true
viewModel.sendVoiceMessage(
voiceHex = voiceHex,
durationSec = durationSec,
waves = waves
)
scope.launch {
delay(120)
listState.animateScrollToItem(0)
delay(220)
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
@@ -3011,6 +3100,7 @@ fun ChatDetailScreen(
else -> {
LazyColumn(
state = listState,
userScrollEnabled = !textSelectionHelper.movingHandle,
modifier =
Modifier.fillMaxSize()
.nestedScroll(
@@ -3150,6 +3240,8 @@ fun ChatDetailScreen(
MessageBubble(
message =
message,
textSelectionHelper =
textSelectionHelper,
isDarkTheme =
isDarkTheme,
hasWallpaper =
@@ -3630,6 +3722,11 @@ fun ChatDetailScreen(
}
}
}
// 🔤 Text selection overlay
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
helper = textSelectionHelper,
modifier = Modifier.fillMaxSize()
)
}
}
}
@@ -3691,16 +3788,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
val videoUris =
selectedMedia.filter { it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
}
}
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption)
if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
}
},
onOpenCamera = {
val imm =
@@ -3792,16 +3905,32 @@ fun ChatDetailScreen(
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
val videoUris =
selectedMedia.filter { it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
}
}
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption)
if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
}
},
onOpenCamera = {
val imm =
@@ -4258,6 +4387,7 @@ private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
@@ -4295,6 +4425,7 @@ private fun ChatInputBarSection(
}
},
onSend = onSend,
onSendVoiceMessage = onSendVoiceMessage,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,

View File

@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
@@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE,
AttachmentType.FILE,
AttachmentType.AVATAR -> {
AttachmentType.AVATAR,
AttachmentType.VIDEO_CIRCLE -> {
hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена.
@@ -853,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
@@ -1625,6 +1629,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
"file" -> AttachmentType.FILE.value
"avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value
"voice" -> AttachmentType.VOICE.value
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
AttachmentType.VIDEO_CIRCLE.value
else -> -1
}
}
@@ -1792,9 +1799,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
)
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE ||
effectiveType == AttachmentType.AVATAR) &&
effectiveType == AttachmentType.AVATAR ||
effectiveType == AttachmentType.VOICE ||
effectiveType == AttachmentType.VIDEO_CIRCLE) &&
blob.isEmpty() &&
attachmentId.isNotEmpty()
) {
@@ -1872,6 +1881,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val forwardedMessages: List<ReplyData> = emptyList()
)
private fun replyLog(msg: String) {
try {
val ctx = getApplication<android.app.Application>()
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
val dir = java.io.File(ctx.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n")
} catch (_: Exception) {}
}
private suspend fun parseReplyFromAttachments(
attachmentsJson: String,
isFromMe: Boolean,
@@ -1887,26 +1906,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
return try {
val attachments = JSONArray(attachmentsJson)
val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null
for (i in 0 until attachments.length()) {
val attachment = attachments.getJSONObject(i)
val type = attachment.optInt("type", 0)
val type = parseAttachmentType(attachment)
// MESSAGES = 1 (цитата)
if (type == 1) {
if (type == AttachmentType.MESSAGES) {
replyLog("=== PARSE REPLY: isFromMe=$isFromMe, hasGroup=${groupPassword != null}, chachaKey=${chachaKey.take(12)}, hasPlainKey=${plainKeyAndNonce != null} ===")
// Данные могут быть в blob или preview
var dataJson = attachment.optString("blob", "")
if (dataJson.isEmpty()) {
dataJson = attachment.optString("preview", "")
replyLog(" blob empty, using preview")
}
if (dataJson.isEmpty()) {
replyLog(" BOTH empty → skip")
continue
}
replyLog(" dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'")
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат
// "iv:ciphertext"
val colonCount = dataJson.count { it == ':' }
@@ -1914,21 +1938,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
val privateKey = myPrivateKey
var decryptionSuccess = false
replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...")
// 🔥 Способ 0: Группа — blob шифруется ключом группы
if (groupPassword != null) {
if (groupPassword != null && !decryptionSuccess) {
replyLog(" [0] group raw key (len=${groupPassword.length})")
try {
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
if (decrypted != null) {
dataJson = decrypted
decryptionSuccess = true
replyLog(" [0] OK raw key")
} else {
replyLog(" [0] raw key → null")
}
} catch (_: Exception) {}
} catch (e: Exception) { replyLog(" [0] raw key EXCEPTION: ${e.message}") }
// Fallback: Desktop v1.2.1+ шифрует hex-версией ключа
if (!decryptionSuccess) {
try {
val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1)
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
replyLog(" [0] trying hex key (len=${hexKey.length})")
val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey)
if (decrypted != null) {
dataJson = decrypted
decryptionSuccess = true
replyLog(" [0] OK hex key")
} else {
replyLog(" [0] hex key → null")
}
} catch (e: Exception) { replyLog(" [0] hex key EXCEPTION: ${e.message}") }
}
}
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
// сообщений)
if (privateKey != null) {
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом
if (privateKey != null && !decryptionSuccess) {
replyLog(" [1] private key")
try {
val decrypted =
CryptoManager.decryptWithPassword(dataJson, privateKey)
@@ -1998,26 +2043,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {}
}
replyLog(" FINAL: decryptionSuccess=$decryptionSuccess")
if (!decryptionSuccess) {
replyLog(" ALL METHODS FAILED → skip")
continue
}
} else {}
} else {
replyLog(" NOT encrypted (no iv:cipher format), treating as plain JSON")
}
val messagesArray =
try {
JSONArray(dataJson)
} catch (e: Exception) {
replyLog(" JSON parse FAILED: ${e.message?.take(50)}")
replyLog(" dataJson preview: '${dataJson.take(80)}'")
continue
}
replyLog(" JSON OK: ${messagesArray.length()} messages")
if (messagesArray.length() > 0) {
val account = myPublicKey ?: return null
val dialogKey = getDialogKey(account, opponentKey ?: "")
// Check if this is a forwarded set or a regular reply
// Desktop doesn't set "forwarded" flag, but sends multiple messages in the array
val firstMsg = messagesArray.getJSONObject(0)
val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1
replyLog(" isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}")
if (isForwardedSet) {
// 🔥 Parse ALL forwarded messages (desktop parity)
@@ -2110,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,
@@ -2120,6 +2171,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKeyPlainHex = fwdChachaKeyPlain
))
}
replyLog(" RESULT: forwarded ${forwardedList.size} messages")
return ParsedReplyResult(
replyData = forwardedList.firstOrNull(),
forwardedMessages = forwardedList
@@ -2135,13 +2187,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val senderNameFromJson = replyMessage.optString("senderName", "")
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
// both myPublicKey and opponentKey — it's a forwarded message from someone else
val isFromThirdParty = replyPublicKey.isNotEmpty() &&
// 🔥 Detect forward:
// - explicit "forwarded" flag always wins
// - third-party heuristic applies ONLY for direct dialogs
// (in groups reply author is naturally "third-party", and that must remain a reply)
val isGroupContext = isGroupDialogKey(opponentKey ?: "") || isGroupDialogKey(dialogKey)
val isFromThirdPartyDirect = !isGroupContext &&
replyPublicKey.isNotEmpty() &&
replyPublicKey != myPublicKey &&
replyPublicKey != opponentKey
val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty
val isForwarded =
replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect
// 📸 Парсим attachments из JSON reply (как в Desktop)
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
@@ -2291,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,
@@ -2308,11 +2364,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
// so it renders with "Forwarded from" header (like multi-forward)
if (isForwarded) {
replyLog(" RESULT: single forward from=${result.senderName}")
return ParsedReplyResult(
replyData = result,
forwardedMessages = listOf(result)
)
}
replyLog(" RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'")
return ParsedReplyResult(replyData = result)
} else {}
} else {}
@@ -2444,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
private fun resolveReplyPreviewText(
text: String,
attachments: List<MessageAttachment>
): String {
if (text.isNotBlank()) return text
return when {
attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
else -> text
}
}
/**
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
* правильного отображения цитаты
@@ -2458,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
)
}
@@ -2485,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
@@ -2517,6 +2590,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video message"
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply"
else -> "Pinned message"
@@ -2883,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 =
@@ -2913,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,
@@ -3084,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)
@@ -4757,6 +4835,530 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
private data class VideoCircleMeta(
val durationSec: Int,
val width: Int,
val height: Int,
val mimeType: String
)
private fun bytesToHex(bytes: ByteArray): String {
val hexChars = "0123456789abcdef".toCharArray()
val output = CharArray(bytes.size * 2)
var index = 0
bytes.forEach { byte ->
val value = byte.toInt() and 0xFF
output[index++] = hexChars[value ushr 4]
output[index++] = hexChars[value and 0x0F]
}
return String(output)
}
private fun resolveVideoCircleMeta(
application: Application,
videoUri: android.net.Uri
): VideoCircleMeta {
var durationSec = 1
var width = 0
var height = 0
val mimeType =
application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank {
val ext =
MimeTypeMap.getFileExtensionFromUrl(videoUri.toString())
?.lowercase(Locale.ROOT)
?: ""
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
}
runCatching {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(application, videoUri)
val durationMs =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION
)
?.toLongOrNull()
?: 0L
val rawWidth =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
)
?.toIntOrNull()
?: 0
val rawHeight =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
)
?.toIntOrNull()
?: 0
val rotation =
retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
)
?.toIntOrNull()
?: 0
retriever.release()
durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
val rotated = rotation == 90 || rotation == 270
width = if (rotated) rawHeight else rawWidth
height = if (rotated) rawWidth else rawHeight
}
return VideoCircleMeta(
durationSec = durationSec,
width = width.coerceAtLeast(0),
height = height.coerceAtLeast(0),
mimeType = mimeType
)
}
private suspend fun encodeVideoUriToHex(
application: Application,
videoUri: android.net.Uri
): String? {
return withContext(Dispatchers.IO) {
runCatching {
application.contentResolver.openInputStream(videoUri)?.use { stream ->
val bytes = stream.readBytes()
if (bytes.isEmpty()) null else bytesToHex(bytes)
}
}.getOrNull()
}
}
/**
* 🎥 Отправка видео-кружка (video note) из URI.
* Использует такой же transport + шифрование пайплайн, как voice attachment.
*/
fun sendVideoCircleFromUri(videoUri: android.net.Uri) {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) {
return
}
if (isSending) {
return
}
val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) }
.getOrDefault(0L)
val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L
if (fileSize > 0L && fileSize > maxBytes) {
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "video_circle_$timestamp"
val meta = resolveVideoCircleMeta(context, videoUri)
val preview = "${meta.durationSec}::${meta.mimeType}"
val optimisticMessage =
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VIDEO_CIRCLE,
preview = preview,
width = meta.width,
height = meta.height,
localUri = videoUri.toString()
)
)
)
addMessageSafely(optimisticMessage)
_inputText.value = ""
backgroundUploadScope.launch {
try {
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", meta.width)
put("height", meta.height)
put("localUri", videoUri.toString())
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
isFromMe = true,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient
)
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
} catch (_: Exception) {
}
try {
val videoHex = encodeVideoUriToHex(context, videoUri)
if (videoHex.isNullOrBlank()) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
return@launch
}
sendVideoCircleMessageInternal(
messageId = messageId,
attachmentId = attachmentId,
timestamp = timestamp,
videoHex = videoHex,
preview = preview,
width = meta.width,
height = meta.height,
recipient = recipient,
sender = sender,
privateKey = privateKey
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
} finally {
isSending = false
}
}
}
private suspend fun sendVideoCircleMessageInternal(
messageId: String,
attachmentId: String,
timestamp: Long,
videoHex: String,
preview: String,
width: Int,
height: Int,
recipient: String,
sender: String,
privateKey: String
) {
var packetSentToProtocol = false
try {
val application = getApplication<Application>()
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext)
val isSavedMessages = (sender == recipient)
val uploadTag =
if (!isSavedMessages) {
TransportManager.uploadFile(attachmentId, encryptedVideoBlob)
} else {
""
}
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val videoAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VIDEO_CIRCLE,
preview = preview,
width = width,
height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = listOf(videoAttachment)
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
packetSentToProtocol = true
}
runCatching {
AttachmentFileManager.saveAttachment(
context = application,
blob = videoHex,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
updateMessageStatusAndAttachmentsInDb(
messageId = messageId,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson
)
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageAttachments(messageId, null)
}
saveDialog("Video message", timestamp, opponentPublicKey = recipient)
} catch (_: Exception) {
if (packetSentToProtocol) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} else {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
}
}
/**
* 🎙️ Отправка голосового сообщения.
* blob хранится как HEX строка opus/webm байт (desktop parity).
* preview формат: "<durationSec>::<wave1,wave2,...>"
*/
fun sendVoiceMessage(
voiceHex: String,
durationSec: Int,
waves: List<Float>
) {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
if (recipient == null || sender == null || privateKey == null) {
return
}
if (isSending) {
return
}
val normalizedVoiceHex = voiceHex.trim()
if (normalizedVoiceHex.isEmpty()) {
return
}
val normalizedDuration = durationSec.coerceAtLeast(1)
val normalizedWaves =
waves.asSequence()
.map { it.coerceIn(0f, 1f) }
.take(120)
.toList()
val wavesPreview =
normalizedWaves.joinToString(",") {
String.format(Locale.US, "%.3f", it)
}
val preview = "$normalizedDuration::$wavesPreview"
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp"
// 1. 🚀 Optimistic UI
val optimisticMessage =
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
type = AttachmentType.VOICE,
preview = preview,
blob = normalizedVoiceHex
)
)
)
addMessageSafely(optimisticMessage)
_inputText.value = ""
viewModelScope.launch(Dispatchers.IO) {
try {
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val encryptedVoiceBlob =
encryptAttachmentPayload(normalizedVoiceHex, encryptionContext)
val isSavedMessages = (sender == recipient)
var uploadTag = ""
if (!isSavedMessages) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob)
}
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val voiceAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VOICE,
preview = preview,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = listOf(voiceAttachment)
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
// Для отправителя сохраняем voice blob локально в encrypted cache.
runCatching {
AttachmentFileManager.saveAttachment(
context = getApplication(),
blob = normalizedVoiceHex,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VOICE.value)
put("preview", preview)
put("blob", "")
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
saveDialog("Voice message", timestamp)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog("Voice message", timestamp)
} finally {
isSending = false
}
}
}
/**
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
*/
@@ -5280,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()
@@ -311,6 +335,21 @@ fun ChatsListScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// Prewarm: capture bitmap on first appear + when drawer opens
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(1000)
if (prewarmedBitmap == null) {
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
}
}
LaunchedEffect(drawerState.isOpen) {
if (drawerState.isOpen) {
kotlinx.coroutines.delay(200)
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
}
}
fun startThemeReveal() {
if (themeRevealActive) {
@@ -324,7 +363,10 @@ fun ChatsListScreen(
val center =
themeToggleCenterInRoot
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
// Use prewarmed bitmap or capture fresh
val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshotBitmap == null) {
onToggleTheme()
return
@@ -333,6 +375,7 @@ fun ChatsListScreen(
val toDark = !isDarkTheme
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
snapshotBitmap.recycle()
onToggleTheme()
return
}
@@ -448,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
@@ -562,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) }
@@ -583,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 → сбросить
@@ -614,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) {
@@ -764,10 +888,6 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
ModalNavigationDrawer(
drawerState = drawerState,
@@ -850,16 +970,16 @@ fun ChatsListScreen(
) {
AvatarImage(
publicKey =
accountPublicKey,
effectiveCurrentPublicKey,
avatarRepository =
avatarRepository,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =
accountName
sidebarAccountName
.ifEmpty {
accountUsername
sidebarAccountUsername
}
)
}
@@ -911,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
@@ -936,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)
)
@@ -980,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,
@@ -1260,6 +1382,9 @@ fun ChatsListScreen(
}
)
// Keep distance from footer divider so it never overlays Settings.
Spacer(modifier = Modifier.height(8.dp))
}
// ═══════════════════════════════════════════════════════════
@@ -1268,9 +1393,12 @@ fun ChatsListScreen(
Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
.windowInsetsPadding(
WindowInsets
.navigationBars
.only(
WindowInsetsSides.Bottom
)
)
) {
// Telegram-style update banner
@@ -2111,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.
@@ -2313,9 +2485,7 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
stickyTopInset
)
.then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
@@ -2553,6 +2723,18 @@ fun ChatsListScreen(
}
}
}
val isVoicePlaybackActive by
remember(
dialog.opponentKey,
playingVoiceDialogKey
) {
derivedStateOf {
isVoicePlayingForDialog(
dialog.opponentKey,
playingVoiceDialogKey
)
}
}
val isSelectedDialog =
selectedChatKeys
.contains(
@@ -2594,6 +2776,8 @@ fun ChatsListScreen(
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
isVoicePlaybackActive =
isVoicePlaybackActive,
isBlocked =
isBlocked,
isSavedMessages =
@@ -2727,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()
}
)
}
}
}
}
}
@@ -3703,6 +3924,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
@@ -4106,6 +4328,7 @@ fun SwipeableDialogItem(
isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
isVoicePlaybackActive = isVoicePlaybackActive,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
@@ -4125,6 +4348,7 @@ fun DialogItemContent(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
@@ -4259,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(
@@ -4274,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))
@@ -4282,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)
@@ -4291,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
)
@@ -4318,6 +4543,7 @@ fun DialogItemContent(
}
Row(
modifier = Modifier.heightIn(min = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
@@ -4448,7 +4674,7 @@ fun DialogItemContent(
0.6f
),
modifier =
Modifier.size(14.dp)
Modifier.size(16.dp)
)
Spacer(
modifier =
@@ -4468,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)
)
}
}
@@ -4487,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: ",
@@ -4508,7 +4753,7 @@ fun DialogItemContent(
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
text = dialog.draftText.orEmpty(),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
@@ -4530,6 +4775,8 @@ fun DialogItemContent(
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
dialog.lastMessageAttachmentType ==
"Voice message" -> "Voice message"
dialog.lastMessageAttachmentType ==
"Forwarded" ->
"Forwarded message"
@@ -4847,6 +5094,167 @@ fun TypingIndicatorSmall(
}
}
@Composable
private fun VoicePlaybackIndicatorSmall(
isDarkTheme: Boolean
) {
val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
val levels = List(3) { index ->
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 900
0f at 0
1f at 280
0.2f at 580
0f at 900
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(index * 130)
),
label = "voiceBar$index"
).value
}
Row(
modifier = Modifier.heightIn(min = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
val barWidth = 2.dp.toPx()
val gap = 2.dp.toPx()
val baseY = size.height
repeat(3) { index ->
val x = index * (barWidth + gap)
val progress = levels[index].coerceIn(0f, 1f)
val minH = 3.dp.toPx()
val maxH = 10.dp.toPx()
val height = minH + (maxH - minH) * progress
drawRoundRect(
color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
topLeft = Offset(x, baseY - height),
size = androidx.compose.ui.geometry.Size(barWidth, height),
cornerRadius =
androidx.compose.ui.geometry.CornerRadius(
x = barWidth,
y = barWidth
)
)
}
}
Spacer(modifier = Modifier.width(5.dp))
AppleEmojiText(
text = "Listening",
fontSize = 14.sp,
color = accentColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false,
minHeightMultiplier = 1f
)
}
}
fun formatVoiceSpeedLabel(speed: Float): String {
val normalized = (speed * 10f).roundToInt() / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x"
} else {
"${normalized}x"
}
}
@Composable
fun VoiceTopMiniPlayer(
title: String,
isDarkTheme: Boolean,
isPlaying: Boolean,
speed: Float,
onTogglePlay: () -> Unit,
onCycleSpeed: () -> Unit,
onClose: () -> Unit
) {
// Match overall screen surface aesthetic — neutral elevated surface, no blue accent
val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
Row(
modifier = Modifier.fillMaxWidth()
.height(40.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onTogglePlay,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector =
if (isPlaying) Icons.Default.Pause
else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
tint = primaryIconColor,
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
AppleEmojiText(
text = title,
fontSize = 14.sp,
color = textColor,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f),
enableLinks = false,
minHeightMultiplier = 1f
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier.clip(RoundedCornerShape(8.dp))
.border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
.clickable { onCycleSpeed() }
.padding(horizontal = 8.dp, vertical = 3.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoiceSpeedLabel(speed),
color = secondaryColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = onClose,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close voice",
tint = secondaryColor,
modifier = Modifier.size(20.dp)
)
}
}
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
}
}
@Composable
private fun SwipeBackContainer(
onBack: () -> Unit,
@@ -5446,7 +5854,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5506,7 +5914,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5540,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 =
@@ -573,16 +643,52 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
lastMessageAttachments: String
): String? {
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
return when (attachmentType) {
val effectiveType =
if (attachmentType >= 0) attachmentType
else inferAttachmentTypeFromJson(lastMessageAttachments)
return when (effectiveType) {
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
5 -> "Voice message" // AttachmentType.VOICE = 5
6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
else -> if (inferredCall) "Call" else null
}
}
private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
return try {
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
if (attachments.length() <= 0) return -1
val first = attachments.optJSONObject(0) ?: return -1
val rawType = first.opt("type")
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> 0
"messages", "reply", "forward" -> 1
"file" -> 2
"avatar" -> 3
"call" -> 4
"voice" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
else -> -1
}
}
else -> -1
}
} catch (_: Throwable) {
-1
}
}
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
return try {

View File

@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
)
}
if (groupDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
AppleEmojiText(
text = groupDescription,
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp,
maxLines = 2,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
if (groupDescription.isNotBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
AppleEmojiText(
text = groupDescription,
color = primaryText,
fontSize = 16.sp,
maxLines = 8,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = true
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Description",
color = Color(0xFF8E8E93),
fontSize = 13.sp
)
}
}
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.fillMaxWidth()
)
}
Surface(
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

@@ -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"
@@ -684,7 +701,18 @@ fun MessageBubble(
message.attachments.all {
it.type ==
com.rosetta.messenger.network.AttachmentType
.IMAGE
.IMAGE ||
it.type ==
com.rosetta.messenger.network
.AttachmentType
.VIDEO_CIRCLE
}
val hasOnlyVideoCircle =
hasOnlyMedia &&
message.attachments.all {
it.type ==
com.rosetta.messenger.network.AttachmentType
.VIDEO_CIRCLE
}
// Фото + caption (как в Telegram)
@@ -707,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() &&
@@ -725,7 +755,8 @@ fun MessageBubble(
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
}
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
val bubbleBorderWidth =
if (hasOnlyMedia && !hasOnlyVideoCircle) 1.dp else 0.dp
// Telegram-style: ширина пузырька = ширина фото
// Caption переносится на новые строки, не расширяя пузырёк
@@ -743,7 +774,9 @@ fun MessageBubble(
// Вычисляем ширину фото для ограничения пузырька
val photoWidth =
if (hasImageWithCaption || hasOnlyMedia) {
if (isImageCollage) {
if (hasOnlyVideoCircle) {
220.dp
} else if (isImageCollage) {
maxCollageWidth
} else {
val firstImage =
@@ -843,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(
@@ -962,6 +1010,7 @@ fun MessageBubble(
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
onClick = { onReplyClick(reply.messageId) },
onImageClick = onImageClick,
@@ -978,10 +1027,12 @@ fun MessageBubble(
MessageAttachments(
attachments = message.attachments,
chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey,
senderDisplayName = senderName,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
timestamp = message.timestamp,
@@ -1050,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 = {
@@ -1141,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(
@@ -1245,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 = {
@@ -2097,6 +2204,7 @@ fun ReplyBubble(
isOutgoing: Boolean,
isDarkTheme: Boolean,
chachaKey: String = "",
chachaKeyPlainHex: String = "",
privateKey: String = "",
onClick: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
@@ -2224,7 +2332,10 @@ fun ReplyBubble(
cacheKey = "img_${imageAttachment.id}",
context = context,
senderPublicKey = replyData.senderPublicKey,
recipientPrivateKey = replyData.recipientPrivateKey
recipientPrivateKey = replyData.recipientPrivateKey,
chachaKeyPlainHex = replyData.chachaKeyPlainHex.ifEmpty {
chachaKeyPlainHex
}
)
if (bitmap != null) imageBitmap = bitmap
}
@@ -2302,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 -> "..."
@@ -3540,6 +3653,7 @@ fun ProfilePhotoMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
onQrCodeClick: (() -> Unit)? = null,
onSetPhotoClick: () -> Unit,
onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false
@@ -3569,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

@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.onboarding
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -90,87 +92,17 @@ fun OnboardingScreen(
// Theme transition animation
var isTransitioning by remember { mutableStateOf(false) }
var transitionProgress by remember { mutableStateOf(0f) }
val transitionRadius = remember { androidx.compose.animation.core.Animatable(0f) }
var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var shouldUpdateStatusBar by remember { mutableStateOf(false) }
var hasInitialized by remember { mutableStateOf(false) }
var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) }
var rootSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { hasInitialized = true }
LaunchedEffect(isTransitioning) {
if (isTransitioning) {
shouldUpdateStatusBar = false
val duration = 800f
val startTime = System.currentTimeMillis()
while (transitionProgress < 1f) {
val elapsed = System.currentTimeMillis() - startTime
transitionProgress = (elapsed / duration).coerceAtMost(1f)
delay(16) // ~60fps
}
// Update status bar icons after animation is completely finished
shouldUpdateStatusBar = true
delay(50) // Small delay to ensure UI updates
isTransitioning = false
transitionProgress = 0f
shouldUpdateStatusBar = false
previousTheme = targetTheme
}
}
// Animate navigation bar color starting at 80% of wave animation
val view = LocalView.current
val isGestureNavigation = remember(view.context) {
NavigationModeUtils.isGestureNavigation(view.context)
}
LaunchedEffect(isTransitioning, transitionProgress) {
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
// Map 0.8-1.0 to 0-1 for smooth interpolation
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF
val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF
val r1 = (oldColor shr 16 and 0xFF)
val g1 = (oldColor shr 8 and 0xFF)
val b1 = (oldColor and 0xFF)
val r2 = (newColor shr 16 and 0xFF)
val g2 = (newColor shr 8 and 0xFF)
val b2 = (newColor and 0xFF)
val r = (r1 + (r2 - r1) * navProgress).toInt()
val g = (g1 + (g2 - g1) * navProgress).toInt()
val b = (b1 + (b2 - b1) * navProgress).toInt()
window.navigationBarColor =
(0xFF000000 or
(r.toLong() shl 16) or
(g.toLong() shl 8) or
b.toLong())
.toInt()
}
}
// Update status bar icons when animation finishes
LaunchedEffect(shouldUpdateStatusBar) {
if (shouldUpdateStatusBar && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar: показываем только если есть нативные кнопки
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = insetsController,
context = view.context,
isDarkTheme = isDarkTheme
)
}
}
// Set initial navigation bar color only on first launch
LaunchedEffect(Unit) {
@@ -221,7 +153,9 @@ fun OnboardingScreen(
label = "indicatorColor"
)
Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()
.onGloballyPositioned { rootSize = it.size }
) {
// Base background - shows the OLD theme color during transition
Box(
modifier =
@@ -237,15 +171,11 @@ fun OnboardingScreen(
// Circular reveal overlay - draws the NEW theme color expanding
if (isTransitioning) {
Canvas(modifier = Modifier.fillMaxSize()) {
val maxRadius = hypot(size.width, size.height)
val radius = maxRadius * transitionProgress
// Draw the NEW theme color expanding from click point
drawCircle(
color =
if (targetTheme) OnboardingBackground
else OnboardingBackgroundLight,
radius = radius,
radius = transitionRadius.value,
center = clickPosition
)
}
@@ -260,6 +190,22 @@ fun OnboardingScreen(
clickPosition = position
isTransitioning = true
onThemeToggle()
scope.launch {
try {
val maxR = hypot(
rootSize.width.toFloat(),
rootSize.height.toFloat()
).coerceAtLeast(1f)
transitionRadius.snapTo(0f)
transitionRadius.animateTo(
targetValue = maxR,
animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f))
)
} finally {
isTransitioning = false
previousTheme = targetTheme
}
}
}
},
modifier =

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]
@@ -132,6 +124,13 @@ fun MyQrCodeScreen(
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var lastRevealTime by remember { mutableLongStateOf(0L) }
val revealCooldownMs = 600L
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// Prewarm bitmap on screen appear
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(300)
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
}
fun startReveal(newIndex: Int, center: Offset) {
val now = System.currentTimeMillis()
@@ -142,7 +141,8 @@ fun MyQrCodeScreen(
return
}
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshot == null) {
selectedThemeIndex = newIndex
return
@@ -264,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(
@@ -291,30 +291,31 @@ 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
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshot != null) {
val maxR = maxRevealRadius(themeButtonPos, rootSize)
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)
@@ -328,11 +329,8 @@ fun MyQrCodeScreen(
revealActive = false
}
}
} else {
// drawToBitmap failed — skip
}
}
// else: cooldown active — ignore tap
},
modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
@@ -341,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
)
}
}
@@ -351,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),
@@ -385,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

@@ -99,6 +99,13 @@ fun ThemeScreen(
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var prewarmedBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// Prewarm bitmap on screen appear
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(300)
prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull()
}
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
@@ -130,7 +137,8 @@ fun ThemeScreen(
val center =
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull()
prewarmedBitmap = null
if (snapshotBitmap == null) {
themeMode = targetMode
onThemeModeChange(targetMode)

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

View File

@@ -0,0 +1,65 @@
package com.rosetta.messenger.ui.chats.input
import org.junit.Assert.*
import org.junit.Test
class VoiceRecordHelpersTest {
@Test
fun `formatVoiceRecordTimer formats zero`() {
assertEquals("0:00,0", formatVoiceRecordTimer(0L))
}
@Test
fun `formatVoiceRecordTimer formats 12300ms`() {
assertEquals("0:12,3", formatVoiceRecordTimer(12300L))
}
@Test
fun `formatVoiceRecordTimer formats 61500ms`() {
assertEquals("1:01,5", formatVoiceRecordTimer(61500L))
}
@Test
fun `formatVoiceRecordTimer handles negative`() {
assertEquals("0:00,0", formatVoiceRecordTimer(-100L))
}
@Test
fun `compressVoiceWaves empty source returns zeros`() {
val result = compressVoiceWaves(emptyList(), 5)
assertEquals(5, result.size)
assertTrue(result.all { it == 0f })
}
@Test
fun `compressVoiceWaves same size returns same`() {
val source = listOf(0.1f, 0.5f, 0.9f)
assertEquals(source, compressVoiceWaves(source, 3))
}
@Test
fun `compressVoiceWaves downsamples by max`() {
val source = listOf(0.1f, 0.8f, 0.3f, 0.9f, 0.2f, 0.7f)
val result = compressVoiceWaves(source, 3)
assertEquals(3, result.size)
assertEquals(0.8f, result[0], 0.01f)
assertEquals(0.9f, result[1], 0.01f)
assertEquals(0.7f, result[2], 0.01f)
}
@Test
fun `compressVoiceWaves target zero returns empty`() {
assertEquals(emptyList<Float>(), compressVoiceWaves(listOf(1f), 0))
}
@Test
fun `compressVoiceWaves upsamples via interpolation`() {
val source = listOf(0.0f, 1.0f)
val result = compressVoiceWaves(source, 3)
assertEquals(3, result.size)
assertEquals(0.0f, result[0], 0.01f)
assertEquals(0.5f, result[1], 0.01f)
assertEquals(1.0f, result[2], 0.01f)
}
}