Релиз 1.5.1: merge dev в master и обновление ReleaseNotes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Some checks failed
Android Kernel Build / build (push) Has been cancelled
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.5.0"
|
||||
val rosettaVersionCode = 52 // Increment on each release
|
||||
val rosettaVersionName = "1.5.1"
|
||||
val rosettaVersionCode = 53 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
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% самых старых записей
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,11 +406,21 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
||||
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
|
||||
if (!networkReady) {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
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++
|
||||
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,
|
||||
|
||||
@@ -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 (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}") }
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// 🔥 Способ 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 {
|
||||
|
||||
@@ -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 normalizedPublicKey = accountPublicKey.trim()
|
||||
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
|
||||
|
||||
val normalizedPrivateKey = accountPrivateKey.trim()
|
||||
val launchStart = System.currentTimeMillis()
|
||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
||||
|
||||
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
RecentSearchesManager.setAccount(accountPublicKey)
|
||||
RecentSearchesManager.setAccount(normalizedPublicKey)
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||
// сообщений только когда приватный ключ уже доступен.
|
||||
if (normalizedPrivateKey.isNotEmpty()) {
|
||||
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||
}
|
||||
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
|
||||
"✅ 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,7 +626,42 @@ 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
|
||||
@@ -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,6 +2911,14 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showStickyCallBanner || showVoiceMiniPlayer) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.align(
|
||||
Alignment.TopCenter
|
||||
)
|
||||
) {
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
@@ -2736,6 +2928,35 @@ fun ChatsListScreen(
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close Requests AnimatedContent
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
.padding(horizontal = 16.dp),
|
||||
color = sectionColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
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(),
|
||||
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,
|
||||
|
||||
@@ -573,7 +573,8 @@ private fun ChatsTabContent(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recent header (always show with Clear All) ───
|
||||
// ─── Recent header (only when there are recents) ───
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -589,7 +590,6 @@ private fun ChatsTabContent(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
Text(
|
||||
"Clear All",
|
||||
fontSize = 13.sp,
|
||||
|
||||
@@ -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,17 +111,22 @@ 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") {
|
||||
Column(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No calls yet",
|
||||
subtitle = "Your call history will appear here",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
||||
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 ->
|
||||
CallHistoryRowItem(
|
||||
@@ -273,41 +283,65 @@ 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
|
||||
) {
|
||||
if (lottieComposition != null) {
|
||||
LottieAnimation(
|
||||
composition = lottieComposition,
|
||||
progress = { lottieProgress },
|
||||
modifier = Modifier.size(184.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(34.dp)
|
||||
tint = subtitleColor,
|
||||
modifier = Modifier.size(52.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
if (title.isNotBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
|
||||
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,11 +1217,21 @@ 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 = {
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.text.Layout
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class LayoutInfo(
|
||||
val layout: Layout,
|
||||
val windowX: Int,
|
||||
val windowY: Int,
|
||||
val text: CharSequence
|
||||
)
|
||||
|
||||
class TextSelectionHelper {
|
||||
|
||||
var selectionStart by mutableIntStateOf(-1)
|
||||
private set
|
||||
var selectionEnd by mutableIntStateOf(-1)
|
||||
private set
|
||||
var selectedMessageId by mutableStateOf<String?>(null)
|
||||
private set
|
||||
// True when the selected message is the user's own (blue bubble) — used to pick
|
||||
// white handles against the blue background instead of the default blue handles.
|
||||
var isOwnMessage by mutableStateOf(false)
|
||||
private set
|
||||
var layoutInfo by mutableStateOf<LayoutInfo?>(null)
|
||||
private set
|
||||
var isActive by mutableStateOf(false)
|
||||
private set
|
||||
var handleViewProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
var movingHandle by mutableStateOf(false)
|
||||
private set
|
||||
var movingHandleStart by mutableStateOf(false)
|
||||
private set
|
||||
// Telegram: isOneTouch = true during initial long-press drag (before finger lifts)
|
||||
var isOneTouch by mutableStateOf(false)
|
||||
private set
|
||||
// Telegram: direction not determined yet — first drag decides start or end handle
|
||||
private var movingDirectionSettling = false
|
||||
private var movingOffsetX = 0f
|
||||
private var movingOffsetY = 0f
|
||||
|
||||
var startHandleX by mutableFloatStateOf(0f)
|
||||
var startHandleY by mutableFloatStateOf(0f)
|
||||
var endHandleX by mutableFloatStateOf(0f)
|
||||
var endHandleY by mutableFloatStateOf(0f)
|
||||
|
||||
// Back gesture callback — registered/unregistered by overlay
|
||||
var backCallback: Any? = null
|
||||
|
||||
// Overlay position in window — set by TextSelectionOverlay
|
||||
var overlayWindowX = 0f
|
||||
var overlayWindowY = 0f
|
||||
|
||||
val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
|
||||
|
||||
fun startSelection(
|
||||
messageId: String,
|
||||
info: LayoutInfo,
|
||||
touchX: Int,
|
||||
touchY: Int,
|
||||
view: View?,
|
||||
isOwnMessage: Boolean = false
|
||||
) {
|
||||
this.isOwnMessage = isOwnMessage
|
||||
val layout = info.layout
|
||||
val localX = touchX - info.windowX
|
||||
val localY = touchY - info.windowY
|
||||
|
||||
val line = layout.getLineForVertical(localY)
|
||||
val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||
val offset = layout.getOffsetForHorizontal(line, hx)
|
||||
|
||||
val text = info.text
|
||||
var start = offset
|
||||
var end = offset
|
||||
|
||||
while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start--
|
||||
while (end < text.length && Character.isLetterOrDigit(text[end])) end++
|
||||
|
||||
if (start == end && end < text.length) end++
|
||||
|
||||
selectedMessageId = messageId
|
||||
layoutInfo = info
|
||||
selectionStart = start
|
||||
selectionEnd = end
|
||||
isActive = true
|
||||
handleViewProgress = 1f
|
||||
|
||||
// Telegram: immediately enter drag mode — user can drag without lifting finger
|
||||
movingHandle = true
|
||||
movingDirectionSettling = true
|
||||
isOneTouch = true
|
||||
movingOffsetX = 0f
|
||||
movingOffsetY = 0f
|
||||
|
||||
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
showToolbar = false
|
||||
}
|
||||
|
||||
fun updateSelectionStart(charOffset: Int) {
|
||||
if (!isActive) return
|
||||
val text = layoutInfo?.text ?: return
|
||||
val newStart = charOffset.coerceIn(0, text.length)
|
||||
if (newStart >= selectionEnd) return
|
||||
val changed = newStart != selectionStart
|
||||
selectionStart = newStart
|
||||
if (changed) hapticOnSelectionChange()
|
||||
}
|
||||
|
||||
fun updateSelectionEnd(charOffset: Int) {
|
||||
if (!isActive) return
|
||||
val text = layoutInfo?.text ?: return
|
||||
val newEnd = charOffset.coerceIn(0, text.length)
|
||||
if (newEnd <= selectionStart) return
|
||||
val changed = newEnd != selectionEnd
|
||||
selectionEnd = newEnd
|
||||
if (changed) hapticOnSelectionChange()
|
||||
}
|
||||
|
||||
private fun hapticOnSelectionChange() {
|
||||
magnifierView?.performHapticFeedback(
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) {
|
||||
movingHandle = true
|
||||
movingHandleStart = isStart
|
||||
movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX
|
||||
movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY
|
||||
}
|
||||
|
||||
fun moveHandle(touchX: Float, touchY: Float) {
|
||||
if (!movingHandle) return
|
||||
val x = (touchX + movingOffsetX).toInt()
|
||||
val y = (touchY + movingOffsetY).toInt()
|
||||
val offset = getCharOffsetFromCoords(x, y)
|
||||
if (offset < 0) return
|
||||
|
||||
// Telegram: first drag determines which handle to move
|
||||
if (movingDirectionSettling) {
|
||||
if (offset < selectionStart) {
|
||||
movingDirectionSettling = false
|
||||
movingHandleStart = true
|
||||
} else if (offset > selectionEnd) {
|
||||
movingDirectionSettling = false
|
||||
movingHandleStart = false
|
||||
} else {
|
||||
return // still within selected word, wait for more movement
|
||||
}
|
||||
}
|
||||
|
||||
if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
|
||||
}
|
||||
|
||||
fun endHandleDrag() {
|
||||
movingHandle = false
|
||||
movingDirectionSettling = false
|
||||
isOneTouch = false
|
||||
showFloatingToolbar()
|
||||
}
|
||||
|
||||
var showToolbar by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun showFloatingToolbar() {
|
||||
if (isInSelectionMode && !movingHandle) {
|
||||
showToolbar = true
|
||||
}
|
||||
}
|
||||
|
||||
fun hideFloatingToolbar() {
|
||||
showToolbar = false
|
||||
}
|
||||
|
||||
private var magnifier: android.widget.Magnifier? = null
|
||||
private var magnifierView: View? = null
|
||||
|
||||
fun setMagnifierView(view: View?) {
|
||||
magnifierView = view
|
||||
}
|
||||
|
||||
fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||
val view = magnifierView ?: return
|
||||
if (!movingHandle) return
|
||||
if (magnifier == null) {
|
||||
magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
android.widget.Magnifier.Builder(view)
|
||||
.setSize(240, 64)
|
||||
.setCornerRadius(12f)
|
||||
.setElevation(4f)
|
||||
.setDefaultSourceToMagnifierOffset(0, -96)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
android.widget.Magnifier(view)
|
||||
}
|
||||
}
|
||||
val info = layoutInfo ?: return
|
||||
|
||||
// Magnifier should show at the HANDLE position (current char), not finger
|
||||
// Use handle X for horizontal, and line center for vertical
|
||||
val handleX = if (movingHandleStart) startHandleX else endHandleX
|
||||
val handleY = if (movingHandleStart) startHandleY else endHandleY
|
||||
val activeOffset = if (movingHandleStart) selectionStart else selectionEnd
|
||||
val layout = info.layout
|
||||
val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length))
|
||||
val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f
|
||||
|
||||
// Convert to view-local coordinates
|
||||
val viewLoc = IntArray(2)
|
||||
view.getLocationInWindow(viewLoc)
|
||||
val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat())
|
||||
val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat())
|
||||
magnifier?.show(sourceX, sourceY)
|
||||
}
|
||||
|
||||
fun hideMagnifier() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||
magnifier?.dismiss()
|
||||
magnifier = null
|
||||
}
|
||||
|
||||
fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int {
|
||||
val info = layoutInfo ?: return -1
|
||||
// overlay-local → text-local: subtract text position relative to overlay
|
||||
val textLocalX = overlayLocalX - (info.windowX - overlayWindowX)
|
||||
val textLocalY = overlayLocalY - (info.windowY - overlayWindowY)
|
||||
val layout = info.layout
|
||||
val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height))
|
||||
val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||
return layout.getOffsetForHorizontal(line, hx)
|
||||
}
|
||||
|
||||
fun getSelectedText(): CharSequence? {
|
||||
if (!isInSelectionMode) return null
|
||||
val text = layoutInfo?.text ?: return null
|
||||
val start = selectionStart.coerceIn(0, text.length)
|
||||
val end = selectionEnd.coerceIn(start, text.length)
|
||||
return text.subSequence(start, end)
|
||||
}
|
||||
|
||||
fun copySelectedText(context: Context) {
|
||||
val selectedText = getSelectedText() ?: return
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText))
|
||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||
clear()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val text = layoutInfo?.text ?: return
|
||||
selectionStart = 0
|
||||
selectionEnd = text.length
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
selectionStart = -1
|
||||
selectionEnd = -1
|
||||
selectedMessageId = null
|
||||
layoutInfo = null
|
||||
isActive = false
|
||||
handleViewProgress = 0f
|
||||
movingHandle = false
|
||||
movingDirectionSettling = false
|
||||
isOneTouch = false
|
||||
showToolbar = false
|
||||
hideMagnifier()
|
||||
}
|
||||
}
|
||||
|
||||
private val HandleSize = 22.dp
|
||||
private val HandleInset = 8.dp
|
||||
private val HighlightCorner = 6.dp
|
||||
private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
|
||||
private val HandleColor = PrimaryBlue
|
||||
|
||||
@Composable
|
||||
private fun FloatingToolbarPopup(
|
||||
helper: TextSelectionHelper
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(helper.isActive, helper.movingHandle) {
|
||||
if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
|
||||
delay(200)
|
||||
helper.showFloatingToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
if (!helper.showToolbar || !helper.isInSelectionMode) return
|
||||
|
||||
val info = helper.layoutInfo ?: return
|
||||
val layout = info.layout
|
||||
val density = LocalDensity.current
|
||||
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||
|
||||
// Toolbar positioned ABOVE selection top, in overlay-local coordinates
|
||||
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||
(info.windowY - helper.overlayWindowY)
|
||||
// Toolbar is ~48dp tall + 8dp gap above selection
|
||||
val toolbarOffsetPx = with(density) { 56.dp.toPx() }
|
||||
val toolbarWidthPx = with(density) { 200.dp.toPx() }
|
||||
val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
|
||||
val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.shadow(4.dp, RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF333333))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Copy",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clickable { helper.copySelectedText(context) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
val allSelected = helper.selectionStart <= 0 &&
|
||||
helper.selectionEnd >= info.text.length
|
||||
if (!allSelected) {
|
||||
Text(
|
||||
text = "Select All",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
helper.selectAll()
|
||||
helper.hideFloatingToolbar()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextSelectionOverlay(
|
||||
helper: TextSelectionHelper,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!helper.isInSelectionMode) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val handleSizePx = with(density) { HandleSize.toPx() }
|
||||
val handleInsetPx = with(density) { HandleInset.toPx() }
|
||||
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
|
||||
// Read isOwnMessage at composition level so Canvas properly invalidates on change.
|
||||
// On own (blue) bubbles use the light-blue typing color — reads better than pure white.
|
||||
val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor
|
||||
val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor
|
||||
|
||||
// Block predictive back gesture completely during text selection.
|
||||
// BackHandler alone doesn't prevent the swipe animation on Android 13+
|
||||
// with enableOnBackInvokedCallback=true. We must register an
|
||||
// OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it.
|
||||
val activity = LocalContext.current as? android.app.Activity
|
||||
LaunchedEffect(helper.isActive) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) {
|
||||
if (helper.isActive) {
|
||||
val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ }
|
||||
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
|
||||
android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb
|
||||
)
|
||||
helper.backCallback = cb
|
||||
} else {
|
||||
helper.backCallback?.let { cb ->
|
||||
runCatching {
|
||||
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(
|
||||
cb as android.window.OnBackInvokedCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
helper.backCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback for Android < 13
|
||||
androidx.activity.compose.BackHandler(enabled = helper.isActive) {
|
||||
// consumed — no navigation back while selecting
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coords ->
|
||||
val pos = coords.positionInWindow()
|
||||
helper.overlayWindowX = pos.x
|
||||
helper.overlayWindowY = pos.y
|
||||
}
|
||||
) {
|
||||
FloatingToolbarPopup(helper = helper)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(helper.isActive) {
|
||||
if (!helper.isActive) return@pointerInput
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull() ?: continue
|
||||
|
||||
when {
|
||||
change.pressed && !helper.movingHandle -> {
|
||||
val x = change.position.x
|
||||
val y = change.position.y
|
||||
val startRect = Rect(
|
||||
helper.startHandleX - handleSizePx / 2 - handleInsetPx,
|
||||
helper.startHandleY - handleInsetPx,
|
||||
helper.startHandleX + handleSizePx / 2 + handleInsetPx,
|
||||
helper.startHandleY + handleSizePx + handleInsetPx
|
||||
)
|
||||
val endRect = Rect(
|
||||
helper.endHandleX - handleSizePx / 2 - handleInsetPx,
|
||||
helper.endHandleY - handleInsetPx,
|
||||
helper.endHandleX + handleSizePx / 2 + handleInsetPx,
|
||||
helper.endHandleY + handleSizePx + handleInsetPx
|
||||
)
|
||||
when {
|
||||
startRect.contains(Offset(x, y)) -> {
|
||||
helper.beginHandleDrag(isStart = true, x, y)
|
||||
helper.hideFloatingToolbar()
|
||||
change.consume()
|
||||
}
|
||||
endRect.contains(Offset(x, y)) -> {
|
||||
helper.beginHandleDrag(isStart = false, x, y)
|
||||
helper.hideFloatingToolbar()
|
||||
change.consume()
|
||||
}
|
||||
else -> {
|
||||
helper.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
change.pressed && helper.movingHandle -> {
|
||||
helper.moveHandle(change.position.x, change.position.y)
|
||||
helper.showMagnifier(change.position.x, change.position.y)
|
||||
change.consume()
|
||||
}
|
||||
!change.pressed && helper.movingHandle -> {
|
||||
helper.hideMagnifier()
|
||||
helper.endHandleDrag()
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val info = helper.layoutInfo ?: return@Canvas
|
||||
val layout = info.layout
|
||||
val text = info.text
|
||||
|
||||
// Convert window coords to overlay-local coords
|
||||
val offsetX = info.windowX - helper.overlayWindowX
|
||||
val offsetY = info.windowY - helper.overlayWindowY
|
||||
|
||||
val startOffset = helper.selectionStart.coerceIn(0, text.length)
|
||||
val endOffset = helper.selectionEnd.coerceIn(0, text.length)
|
||||
if (startOffset >= endOffset) return@Canvas
|
||||
|
||||
val startLine = layout.getLineForOffset(startOffset)
|
||||
val endLine = layout.getLineForOffset(endOffset)
|
||||
|
||||
// Padding around highlight for breathing room
|
||||
val padH = 3.dp.toPx()
|
||||
val padV = 2.dp.toPx()
|
||||
|
||||
// Build a single unified Path from all per-line rects, then fill once.
|
||||
// This avoids double-alpha artifacts where adjacent lines' padding overlaps.
|
||||
val highlightPath = Path()
|
||||
for (line in startLine..endLine) {
|
||||
// Only pad the outer edges (top of first line, bottom of last line).
|
||||
// Inner edges meet at lineBottom == nextLineTop so the union fills fully.
|
||||
val topPad = if (line == startLine) padV else 0f
|
||||
val bottomPad = if (line == endLine) padV else 0f
|
||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad
|
||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad
|
||||
val left = if (line == startLine) {
|
||||
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
|
||||
} else {
|
||||
layout.getLineLeft(line) + offsetX - padH
|
||||
}
|
||||
val right = if (line == endLine) {
|
||||
layout.getPrimaryHorizontal(endOffset) + offsetX + padH
|
||||
} else {
|
||||
layout.getLineRight(line) + offsetX + padH
|
||||
}
|
||||
highlightPath.addRoundRect(
|
||||
RoundRect(
|
||||
rect = Rect(left, lineTop, right, lineBottom),
|
||||
cornerRadius = CornerRadius(highlightCornerPx)
|
||||
)
|
||||
)
|
||||
}
|
||||
drawPath(path = highlightPath, color = highlightColor)
|
||||
|
||||
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
|
||||
val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||
val endHy = layout.getLineBottom(endLine).toFloat() + offsetY
|
||||
|
||||
helper.startHandleX = startHx
|
||||
helper.startHandleY = startHy
|
||||
helper.endHandleX = endHx
|
||||
helper.endHandleY = endHy
|
||||
|
||||
drawStartHandle(startHx, startHy, handleSizePx, handleColor)
|
||||
drawEndHandle(endHx, endHy, handleSizePx, handleColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) {
|
||||
val half = size / 2f
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(half, half)
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) {
|
||||
val half = size / 2f
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - half, y),
|
||||
size = Size(half, half)
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -558,6 +558,10 @@ fun AppleEmojiText(
|
||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
fun computeCurrentOffset(): Float {
|
||||
val base = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.value
|
||||
} else {
|
||||
0f
|
||||
} else 0f
|
||||
return base + enter
|
||||
}
|
||||
val currentOffset = baseOffset + enterOffset
|
||||
|
||||
// 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,12 +386,14 @@ 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.
|
||||
try {
|
||||
while (true) {
|
||||
val pass =
|
||||
if (startedSwipe || !deferToChildren)
|
||||
@@ -365,6 +407,7 @@ fun SwipeBackContainer(
|
||||
?: break
|
||||
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
resetOnFinally = false
|
||||
break
|
||||
}
|
||||
|
||||
@@ -444,6 +487,13 @@ fun SwipeBackContainer(
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Сбрасываем только при отмене/прерывании жеста.
|
||||
// При обычном UP сброс делаем позже, чтобы не было рывка.
|
||||
if (resetOnFinally && isDragging) {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.rosetta.messenger.ui.settings
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.ChevronLeft
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AppIconOption(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val subtitle: String,
|
||||
val aliasName: String,
|
||||
val iconRes: Int,
|
||||
val previewBg: Color
|
||||
)
|
||||
|
||||
private val iconOptions = listOf(
|
||||
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)),
|
||||
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White),
|
||||
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White),
|
||||
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppIconScreen(
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val prefs = remember { PreferencesManager(context) }
|
||||
var currentIcon by remember { mutableStateOf("default") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentIcon = prefs.appIcon.first()
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
|
||||
|
||||
// Status bar
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
DisposableEffect(isDarkTheme) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
val prev = insetsController.isAppearanceLightStatusBars
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
onDispose { insetsController.isAppearanceLightStatusBars = prev }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// TOP BAR — same style as SafetyScreen
|
||||
// ═══════════════════════════════════════════════════════
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = backgroundColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronLeft,
|
||||
contentDescription = "Back",
|
||||
tint = textColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "App Icon",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CONTENT
|
||||
// ═══════════════════════════════════════════════════════
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Section header
|
||||
Text(
|
||||
text = "CHOOSE ICON",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = secondaryTextColor,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
// Icon cards in grouped surface (Telegram style)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = surfaceColor
|
||||
) {
|
||||
Column {
|
||||
iconOptions.forEachIndexed { index, option ->
|
||||
val isSelected = currentIcon == option.id
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (!isSelected) {
|
||||
scope.launch {
|
||||
changeAppIcon(context, prefs, option.id)
|
||||
currentIcon = option.id
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon preview
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(option.previewBg),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val imgSize = if (option.id == "default") 52.dp else 44.dp
|
||||
val imgScale = if (option.id == "default")
|
||||
android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
else
|
||||
android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.ImageView(ctx).apply {
|
||||
setImageResource(option.iconRes)
|
||||
scaleType = imgScale
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(imgSize)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
// Label + subtitle
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = option.label,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
Text(
|
||||
text = option.subtitle,
|
||||
color = secondaryTextColor,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
|
||||
// Checkmark
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Divider between items (not after last)
|
||||
if (index < iconOptions.lastIndex) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 82.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = dividerColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Info text below
|
||||
Text(
|
||||
text = "The app icon and name on your home screen will change. Rosetta will continue to work normally. The launcher may take a moment to update.",
|
||||
fontSize = 13.sp,
|
||||
color = secondaryTextColor,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun changeAppIcon(context: Context, prefs: PreferencesManager, newIconId: String) {
|
||||
val pm = context.packageManager
|
||||
val packageName = context.packageName
|
||||
|
||||
iconOptions.forEach { option ->
|
||||
val component = ComponentName(packageName, "$packageName${option.aliasName}")
|
||||
pm.setComponentEnabledSetting(
|
||||
component,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
|
||||
val selected = iconOptions.first { it.id == newIconId }
|
||||
val component = ComponentName(packageName, "$packageName${selected.aliasName}")
|
||||
pm.setComponentEnabledSetting(
|
||||
component,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
prefs.setAppIcon(newIconId)
|
||||
Toast.makeText(context, "Icon changed to ${selected.label}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fun AppearanceScreen(
|
||||
onBack: () -> Unit,
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +1029,21 @@ fun ProfileScreen(
|
||||
x = (-16).dp,
|
||||
y = headerHeight - cameraButtonSize / 2
|
||||
)
|
||||
.graphicsLayer { alpha = floatingButtonsAlpha }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A)
|
||||
else Color(0xFF0D8CF4)
|
||||
)
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -1049,6 +1056,7 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🖼️ Кастомный быстрый Photo Picker
|
||||
ProfilePhotoPicker(
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
4
app/src/main/res/drawable/ic_calc_background.xml
Normal file
4
app/src/main/res/drawable/ic_calc_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/ic_calc_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_calc_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_calc_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
4
app/src/main/res/drawable/ic_notes_background.xml
Normal file
4
app/src/main/res/drawable/ic_notes_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/ic_notes_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_notes_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_notes_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
4
app/src/main/res/drawable/ic_weather_background.xml
Normal file
4
app/src/main/res/drawable/ic_weather_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/ic_weather_foreground.xml
Normal file
7
app/src/main/res/drawable/ic_weather_foreground.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_weather_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_calc_background"/>
|
||||
<foreground android:drawable="@drawable/ic_calc_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_notes_background"/>
|
||||
<foreground android:drawable="@drawable/ic_notes_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_weather_background"/>
|
||||
<foreground android:drawable="@drawable/ic_weather_foreground"/>
|
||||
</adaptive-icon>
|
||||
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/res/raw/phone_duck.json
Normal file
1
app/src/main/res/raw/phone_duck.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class TextSelectionHelperTest {
|
||||
|
||||
private lateinit var helper: TextSelectionHelper
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
helper = TextSelectionHelper()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is not active`() {
|
||||
assertFalse(helper.isActive)
|
||||
assertFalse(helper.isInSelectionMode)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
assertNull(helper.selectedMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear resets all state`() {
|
||||
helper.clear()
|
||||
assertFalse(helper.isActive)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
assertNull(helper.selectedMessageId)
|
||||
assertNull(helper.layoutInfo)
|
||||
assertFalse(helper.showToolbar)
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSelectedText returns null when not active`() {
|
||||
assertNull(helper.getSelectedText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateSelectionEnd does not change when not active`() {
|
||||
helper.updateSelectionEnd(5)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateSelectionStart does not change when not active`() {
|
||||
helper.updateSelectionStart(0)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharOffsetFromCoords returns -1 when no layout`() {
|
||||
assertEquals(-1, helper.getCharOffsetFromCoords(100, 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectAll does nothing when no layout`() {
|
||||
helper.selectAll()
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveHandle does nothing when not moving`() {
|
||||
helper.moveHandle(100f, 100f)
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `endHandleDrag sets movingHandle to false`() {
|
||||
helper.endHandleDrag()
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.rosetta.messenger.ui.chats.input
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceRecordHelpersTest {
|
||||
|
||||
@Test
|
||||
fun `formatVoiceRecordTimer formats zero`() {
|
||||
assertEquals("0:00,0", formatVoiceRecordTimer(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatVoiceRecordTimer formats 12300ms`() {
|
||||
assertEquals("0:12,3", formatVoiceRecordTimer(12300L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatVoiceRecordTimer formats 61500ms`() {
|
||||
assertEquals("1:01,5", formatVoiceRecordTimer(61500L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatVoiceRecordTimer handles negative`() {
|
||||
assertEquals("0:00,0", formatVoiceRecordTimer(-100L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressVoiceWaves empty source returns zeros`() {
|
||||
val result = compressVoiceWaves(emptyList(), 5)
|
||||
assertEquals(5, result.size)
|
||||
assertTrue(result.all { it == 0f })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressVoiceWaves same size returns same`() {
|
||||
val source = listOf(0.1f, 0.5f, 0.9f)
|
||||
assertEquals(source, compressVoiceWaves(source, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressVoiceWaves downsamples by max`() {
|
||||
val source = listOf(0.1f, 0.8f, 0.3f, 0.9f, 0.2f, 0.7f)
|
||||
val result = compressVoiceWaves(source, 3)
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(0.8f, result[0], 0.01f)
|
||||
assertEquals(0.9f, result[1], 0.01f)
|
||||
assertEquals(0.7f, result[2], 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressVoiceWaves target zero returns empty`() {
|
||||
assertEquals(emptyList<Float>(), compressVoiceWaves(listOf(1f), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressVoiceWaves upsamples via interpolation`() {
|
||||
val source = listOf(0.0f, 1.0f)
|
||||
val result = compressVoiceWaves(source, 3)
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(0.0f, result[0], 0.01f)
|
||||
assertEquals(0.5f, result[1], 0.01f)
|
||||
assertEquals(1.0f, result[2], 0.01f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user