Compare commits
20 Commits
aa3cc76646
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d21769399 | |||
| 060d0cbd12 | |||
| 4396611355 | |||
| ce7f913de7 | |||
| cb920b490d | |||
| b1fc623f5e | |||
| ad08af7f0c | |||
| 9fe5f35923 | |||
| 78925dd61d | |||
| 1ac3d93f74 | |||
| 6ad24974e0 | |||
| e825a1ef30 | |||
| 7fcf1195e1 | |||
| a10482b794 | |||
| 419761e34d | |||
| 988896c080 | |||
| b57e48fe20 | |||
| 5c02ff6fd3 | |||
| 7630aa6874 | |||
| afebbf6acb |
@@ -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"
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
if (cacheKey != null) {
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||
|
||||
// 🚀 Сохраняем в кэш (lock-free)
|
||||
if (result != null) {
|
||||
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||
// Ограничиваем размер кэша
|
||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||
// Удаляем ~10% самых старых записей
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,10 +406,20 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
||||
return
|
||||
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
|
||||
return
|
||||
}
|
||||
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
runCatching { webSocket?.cancel() }
|
||||
webSocket = null
|
||||
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
|
||||
}
|
||||
|
||||
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||
@@ -424,6 +437,7 @@ class Protocol(
|
||||
|
||||
// Устанавливаем флаг ПЕРЕД любыми операциями
|
||||
isConnecting = true
|
||||
connectingSinceMs = now
|
||||
|
||||
reconnectAttempts++
|
||||
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
||||
@@ -455,6 +469,7 @@ class Protocol(
|
||||
|
||||
// Сбрасываем флаг подключения
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
|
||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||
// Flush queue as soon as socket is open.
|
||||
@@ -500,6 +515,7 @@ class Protocol(
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
handleDisconnect()
|
||||
}
|
||||
|
||||
@@ -511,6 +527,7 @@ class Protocol(
|
||||
log(" Reconnect attempts: $reconnectAttempts")
|
||||
t.printStackTrace()
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
_lastError.value = t.message
|
||||
handleDisconnect()
|
||||
}
|
||||
@@ -801,6 +818,7 @@ class Protocol(
|
||||
log("🔌 Manual disconnect requested")
|
||||
isManuallyClosed = true
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
||||
reconnectJob = null
|
||||
handshakeJob?.cancel()
|
||||
@@ -823,6 +841,7 @@ class Protocol(
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
||||
val currentState = _state.value
|
||||
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
log(
|
||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
||||
@@ -830,12 +849,22 @@ class Protocol(
|
||||
|
||||
if (!hasCredentials) return
|
||||
|
||||
if (
|
||||
if (currentState == ProtocolState.CONNECTING && isConnecting) {
|
||||
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||
return
|
||||
}
|
||||
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
runCatching { webSocket?.cancel() }
|
||||
webSocket = null
|
||||
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
|
||||
} else if (
|
||||
currentState == ProtocolState.AUTHENTICATED ||
|
||||
currentState == ProtocolState.HANDSHAKING ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
currentState == ProtocolState.CONNECTED ||
|
||||
(currentState == ProtocolState.CONNECTING && isConnecting)
|
||||
currentState == ProtocolState.CONNECTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||
import com.rosetta.messenger.ui.chats.components.*
|
||||
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
|
||||
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
|
||||
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.input.*
|
||||
@@ -324,7 +325,9 @@ fun ChatDetailScreen(
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onImageViewerChanged: (Boolean) -> Unit = {},
|
||||
isCallActive: Boolean = false,
|
||||
onOpenCallOverlay: () -> Unit = {}
|
||||
onOpenCallOverlay: () -> Unit = {},
|
||||
onSelectionModeChange: (Boolean) -> Unit = {},
|
||||
registerClearSelection: (() -> Unit) -> Unit = {}
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
val context = LocalContext.current
|
||||
@@ -389,11 +392,23 @@ fun ChatDetailScreen(
|
||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||
|
||||
// Notify parent about selection mode changes so it can intercept swipe-back
|
||||
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
|
||||
// Register selection-clear callback so parent can cancel selection on swipe-back
|
||||
DisposableEffect(Unit) {
|
||||
registerClearSelection { selectedMessages = emptySet() }
|
||||
onDispose { registerClearSelection {} }
|
||||
}
|
||||
// После long press AndroidView текста может прислать tap на отпускание.
|
||||
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// 🔤 TEXT SELECTION - Telegram-style character-level selection
|
||||
val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() }
|
||||
LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) }
|
||||
|
||||
// 💬 MESSAGE CONTEXT MENU STATE
|
||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
@@ -437,11 +452,29 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker = false
|
||||
}
|
||||
|
||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||
// 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
|
||||
val hideKeyboardAndBack: () -> Unit = {
|
||||
hideInputOverlays()
|
||||
onBack()
|
||||
}
|
||||
// 🔥 Поведение как у нативного Android back:
|
||||
// сначала закрываем IME/emoji, и только следующим back выходим из чата.
|
||||
val handleBackWithInputPriority: () -> Unit = {
|
||||
val imeVisible =
|
||||
androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true
|
||||
val hasInputOverlay =
|
||||
showEmojiPicker ||
|
||||
coordinator.isEmojiBoxVisible ||
|
||||
coordinator.isKeyboardVisible ||
|
||||
imeVisible
|
||||
|
||||
if (hasInputOverlay) {
|
||||
hideInputOverlays()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем это Saved Messages или обычный чат
|
||||
val isSavedMessages = user.publicKey == currentUserPublicKey
|
||||
@@ -611,6 +644,8 @@ fun ChatDetailScreen(
|
||||
showImageViewer,
|
||||
showMediaPicker,
|
||||
showEmojiPicker,
|
||||
textSelectionHelper.isActive,
|
||||
textSelectionHelper.movingHandle,
|
||||
pendingCameraPhotoUri,
|
||||
pendingGalleryImages,
|
||||
showInAppCamera,
|
||||
@@ -620,6 +655,8 @@ fun ChatDetailScreen(
|
||||
showImageViewer ||
|
||||
showMediaPicker ||
|
||||
showEmojiPicker ||
|
||||
textSelectionHelper.isActive ||
|
||||
textSelectionHelper.movingHandle ||
|
||||
pendingCameraPhotoUri != null ||
|
||||
pendingGalleryImages.isNotEmpty() ||
|
||||
showInAppCamera ||
|
||||
@@ -838,6 +875,7 @@ fun ChatDetailScreen(
|
||||
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||
{ messageId, canSelect ->
|
||||
textSelectionHelper.clear()
|
||||
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||
selectedMessages = selectedMessages + messageId
|
||||
}
|
||||
@@ -886,6 +924,13 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔤 Сброс текстового выделения при скролле
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (listState.isScrollInProgress && textSelectionHelper.isActive) {
|
||||
textSelectionHelper.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
||||
val displayReplyMessages =
|
||||
remember(replyMessages, messages) {
|
||||
@@ -1325,10 +1370,10 @@ fun ChatDetailScreen(
|
||||
|
||||
// 🔥 Обработка системной кнопки назад
|
||||
BackHandler {
|
||||
if (isInChatSearchMode) {
|
||||
closeInChatSearch()
|
||||
} else {
|
||||
hideKeyboardAndBack()
|
||||
when {
|
||||
isSelectionMode -> selectedMessages = emptySet()
|
||||
isInChatSearchMode -> closeInChatSearch()
|
||||
else -> handleBackWithInputPriority()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1843,7 +1888,7 @@ fun ChatDetailScreen(
|
||||
Box {
|
||||
IconButton(
|
||||
onClick =
|
||||
hideKeyboardAndBack,
|
||||
handleBackWithInputPriority,
|
||||
modifier =
|
||||
Modifier.size(
|
||||
40.dp
|
||||
@@ -2289,6 +2334,36 @@ fun ChatDetailScreen(
|
||||
avatarRepository = avatarRepository
|
||||
)
|
||||
}
|
||||
// Voice mini player — shown right under the chat header when audio is playing
|
||||
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||
AnimatedVisibility(
|
||||
visible = !playingVoiceAttachmentId.isNullOrBlank(),
|
||||
enter = expandVertically(
|
||||
animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||
expandFrom = Alignment.Top
|
||||
) + fadeIn(animationSpec = tween(220)),
|
||||
exit = shrinkVertically(
|
||||
animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||
shrinkTowards = Alignment.Top
|
||||
) + fadeOut(animationSpec = tween(180))
|
||||
) {
|
||||
val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
|
||||
val time = playingVoiceTimeLabel.trim()
|
||||
val voiceTitle = if (time.isBlank()) sender else "$sender at $time"
|
||||
VoiceTopMiniPlayer(
|
||||
title = voiceTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isPlaying = isVoicePlaybackRunning,
|
||||
speed = voicePlaybackSpeed,
|
||||
onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() },
|
||||
onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() },
|
||||
onClose = { VoicePlaybackCoordinator.stop() }
|
||||
)
|
||||
}
|
||||
} // Закрытие Column topBar
|
||||
},
|
||||
containerColor = backgroundColor, // Фон всего чата
|
||||
@@ -3025,6 +3100,7 @@ fun ChatDetailScreen(
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
userScrollEnabled = !textSelectionHelper.movingHandle,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.nestedScroll(
|
||||
@@ -3164,6 +3240,8 @@ fun ChatDetailScreen(
|
||||
MessageBubble(
|
||||
message =
|
||||
message,
|
||||
textSelectionHelper =
|
||||
textSelectionHelper,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
hasWallpaper =
|
||||
@@ -3644,6 +3722,11 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔤 Text selection overlay
|
||||
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
|
||||
helper = textSelectionHelper,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isOutgoing = fm.isOutgoing,
|
||||
publicKey = fm.senderPublicKey,
|
||||
senderName = fm.senderName,
|
||||
attachments = fm.attachments
|
||||
attachments = fm.attachments,
|
||||
chachaKeyPlainHex = fm.chachaKeyPlain
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = true
|
||||
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
forwardedList.add(ReplyData(
|
||||
messageId = fwdMessageId,
|
||||
senderName = senderDisplayName,
|
||||
text = fwdText,
|
||||
text = resolveReplyPreviewText(fwdText, fwdAttachments),
|
||||
isFromMe = fwdIsFromMe,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = realMessageId,
|
||||
senderName = resolvedSenderName,
|
||||
text = replyText,
|
||||
text = resolveReplyPreviewText(replyText, originalAttachments),
|
||||
isFromMe = isReplyFromMe,
|
||||
isForwarded = isForwarded,
|
||||
forwardedFromName = forwardFromDisplay,
|
||||
@@ -2501,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveReplyPreviewText(
|
||||
text: String,
|
||||
attachments: List<MessageAttachment>
|
||||
): String {
|
||||
if (text.isNotBlank()) return text
|
||||
return when {
|
||||
attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||
attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||
else -> text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
|
||||
* правильного отображения цитаты
|
||||
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
msg.senderPublicKey.trim().ifEmpty {
|
||||
if (msg.isOutgoing) sender else opponent
|
||||
}
|
||||
val resolvedAttachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
ReplyMessage(
|
||||
messageId = msg.id,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
publicKey = resolvedPublicKey,
|
||||
senderName = msg.senderName,
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES },
|
||||
attachments = resolvedAttachments,
|
||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||
)
|
||||
}
|
||||
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
msg.senderPublicKey.trim().ifEmpty {
|
||||
if (msg.isOutgoing) sender else opponent
|
||||
}
|
||||
val resolvedAttachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
ReplyMessage(
|
||||
messageId = msg.id,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
publicKey = resolvedPublicKey,
|
||||
senderName = msg.senderName,
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
attachments = resolvedAttachments,
|
||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = true
|
||||
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = firstReply.messageId,
|
||||
senderName = firstReplySenderName,
|
||||
text = firstReply.text,
|
||||
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
|
||||
isFromMe = firstReply.isOutgoing,
|
||||
isForwarded = isForward,
|
||||
forwardedFromName =
|
||||
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = msg.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
isFromMe = msg.isOutgoing,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (isForwardToSend) {
|
||||
put("forwarded", true)
|
||||
put("senderName", msg.senderName)
|
||||
if (msg.chachaKeyPlainHex.isNotEmpty()) {
|
||||
put("chacha_key_plain", msg.chachaKeyPlainHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
replyJsonArray.put(replyJson)
|
||||
@@ -5863,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
|
||||
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
|
||||
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
|
||||
return
|
||||
}
|
||||
|
||||
val privateKey =
|
||||
myPrivateKey
|
||||
?: run {
|
||||
|
||||
@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -62,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
@@ -74,6 +79,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
||||
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
@@ -222,6 +228,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
|
||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean {
|
||||
val active = playingDialogKey?.trim().orEmpty()
|
||||
if (active.isEmpty()) return false
|
||||
if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) {
|
||||
return normalizeGroupDialogKey(dialogKey).equals(
|
||||
normalizeGroupDialogKey(active),
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
return dialogKey.trim().equals(active, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun shortPublicKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.length <= 12) return trimmed
|
||||
@@ -237,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
|
||||
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
|
||||
}
|
||||
|
||||
private fun rosettaDev1Log(context: Context, tag: String, message: String) {
|
||||
runCatching {
|
||||
val dir = java.io.File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
|
||||
}
|
||||
}
|
||||
|
||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -297,9 +324,6 @@ fun ChatsListScreen(
|
||||
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -467,23 +491,44 @@ fun ChatsListScreen(
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
|
||||
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
// Load dialogs as soon as public key is available.
|
||||
// Private key may appear a bit later right after fresh registration on some devices.
|
||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
||||
val launchStart = System.currentTimeMillis()
|
||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
RecentSearchesManager.setAccount(accountPublicKey)
|
||||
val normalizedPublicKey = accountPublicKey.trim()
|
||||
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
|
||||
)
|
||||
val normalizedPrivateKey = accountPrivateKey.trim()
|
||||
val launchStart = System.currentTimeMillis()
|
||||
|
||||
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
RecentSearchesManager.setAccount(normalizedPublicKey)
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений только когда приватный ключ уже доступен.
|
||||
if (normalizedPrivateKey.isNotEmpty()) {
|
||||
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||
}
|
||||
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
"✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " +
|
||||
"in ${System.currentTimeMillis() - launchStart}ms"
|
||||
)
|
||||
rosettaDev1Log(
|
||||
context = context,
|
||||
tag = "ChatsListScreen",
|
||||
message =
|
||||
"Account init effect pub=${shortPublicKey(normalizedPublicKey)} " +
|
||||
"privReady=${normalizedPrivateKey.isNotEmpty()}"
|
||||
)
|
||||
}
|
||||
|
||||
// Status dialog state
|
||||
@@ -581,9 +626,44 @@ fun ChatsListScreen(
|
||||
LaunchedEffect(accountPublicKey) {
|
||||
val accountManager = AccountManager(context)
|
||||
val accounts = accountManager.getAllAccounts()
|
||||
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
||||
val preferredPublicKey =
|
||||
accountPublicKey.trim().ifBlank {
|
||||
accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
}
|
||||
allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
|
||||
}
|
||||
|
||||
val effectiveCurrentPublicKey =
|
||||
remember(accountPublicKey, allAccounts) {
|
||||
accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() }
|
||||
}
|
||||
val currentSidebarAccount =
|
||||
remember(allAccounts, effectiveCurrentPublicKey) {
|
||||
allAccounts.firstOrNull {
|
||||
it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true)
|
||||
} ?: allAccounts.firstOrNull()
|
||||
}
|
||||
val sidebarAccountUsername =
|
||||
remember(accountUsername, currentSidebarAccount) {
|
||||
accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() }
|
||||
}
|
||||
val sidebarAccountName =
|
||||
remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) {
|
||||
val preferredName =
|
||||
when {
|
||||
accountName.isNotBlank() &&
|
||||
!isPlaceholderAccountName(accountName) -> accountName
|
||||
!currentSidebarAccount?.name.isNullOrBlank() ->
|
||||
currentSidebarAccount?.name.orEmpty()
|
||||
else -> accountName
|
||||
}
|
||||
resolveAccountDisplayName(
|
||||
effectiveCurrentPublicKey,
|
||||
preferredName,
|
||||
sidebarAccountUsername
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation dialogs state
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
@@ -602,7 +682,7 @@ fun ChatsListScreen(
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var showSelectionMenu by remember { mutableStateOf(false) }
|
||||
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
||||
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
|
||||
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
|
||||
.collectAsState(initial = emptySet())
|
||||
|
||||
// Back: drawer → закрыть, selection → сбросить
|
||||
@@ -633,6 +713,31 @@ fun ChatsListScreen(
|
||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
|
||||
|
||||
// Anti-stuck guard:
|
||||
// если соединение уже AUTHENTICATED и синхронизация завершена,
|
||||
// loading не должен висеть бесконечно.
|
||||
LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) {
|
||||
val normalizedPublicKey = accountPublicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
|
||||
if (!topLevelIsLoading) return@LaunchedEffect
|
||||
if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect
|
||||
|
||||
delay(1200)
|
||||
if (
|
||||
topLevelIsLoading &&
|
||||
protocolState == ProtocolState.AUTHENTICATED &&
|
||||
!syncInProgress
|
||||
) {
|
||||
rosettaDev1Log(
|
||||
context = context,
|
||||
tag = "ChatsListScreen",
|
||||
message =
|
||||
"loading guard fired pub=${shortPublicKey(normalizedPublicKey)}"
|
||||
)
|
||||
chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync")
|
||||
}
|
||||
}
|
||||
|
||||
// Dev console dialog - commented out for now
|
||||
/*
|
||||
if (showDevConsole) {
|
||||
@@ -783,10 +888,6 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.onSizeChanged { rootSize = it }
|
||||
.background(backgroundColor)
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -869,16 +970,16 @@ fun ChatsListScreen(
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey =
|
||||
accountPublicKey,
|
||||
effectiveCurrentPublicKey,
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
size = 72.dp,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
displayName =
|
||||
accountName
|
||||
sidebarAccountName
|
||||
.ifEmpty {
|
||||
accountUsername
|
||||
sidebarAccountUsername
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -930,13 +1031,13 @@ fun ChatsListScreen(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Display name
|
||||
if (accountName.isNotEmpty()) {
|
||||
if (sidebarAccountName.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = accountName,
|
||||
text = sidebarAccountName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
@@ -955,10 +1056,10 @@ fun ChatsListScreen(
|
||||
}
|
||||
|
||||
// Username
|
||||
if (accountUsername.isNotEmpty()) {
|
||||
if (sidebarAccountUsername.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "@$accountUsername",
|
||||
text = "@$sidebarAccountUsername",
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
@@ -999,7 +1100,9 @@ fun ChatsListScreen(
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// All accounts list (max 5 like Telegram sidebar behavior)
|
||||
allAccounts.take(5).forEach { account ->
|
||||
val isCurrentAccount = account.publicKey == accountPublicKey
|
||||
val isCurrentAccount =
|
||||
account.publicKey ==
|
||||
effectiveCurrentPublicKey
|
||||
val displayName =
|
||||
resolveAccountDisplayName(
|
||||
account.publicKey,
|
||||
@@ -1279,6 +1382,9 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// Keep distance from footer divider so it never overlays Settings.
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1287,9 +1393,12 @@ fun ChatsListScreen(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets
|
||||
.navigationBars
|
||||
.only(
|
||||
WindowInsetsSides.Bottom
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Telegram-style update banner
|
||||
@@ -2130,6 +2239,50 @@ fun ChatsListScreen(
|
||||
callUiState.phase != CallPhase.INCOMING
|
||||
}
|
||||
val callBannerHeight = 40.dp
|
||||
val showVoiceMiniPlayer =
|
||||
remember(
|
||||
showRequestsScreen,
|
||||
showDownloadsScreen,
|
||||
showCallsScreen,
|
||||
playingVoiceAttachmentId
|
||||
) {
|
||||
!showRequestsScreen &&
|
||||
!showDownloadsScreen &&
|
||||
!showCallsScreen &&
|
||||
!playingVoiceAttachmentId.isNullOrBlank()
|
||||
}
|
||||
val voiceBannerHeight = 36.dp
|
||||
val stickyTopInset =
|
||||
remember(
|
||||
showStickyCallBanner,
|
||||
showVoiceMiniPlayer
|
||||
) {
|
||||
var topInset = 0.dp
|
||||
if (showStickyCallBanner) {
|
||||
topInset += callBannerHeight
|
||||
}
|
||||
if (showVoiceMiniPlayer) {
|
||||
topInset += voiceBannerHeight
|
||||
}
|
||||
topInset
|
||||
}
|
||||
val voiceMiniPlayerTitle =
|
||||
remember(
|
||||
playingVoiceSenderLabel,
|
||||
playingVoiceTimeLabel
|
||||
) {
|
||||
val sender =
|
||||
playingVoiceSenderLabel
|
||||
.trim()
|
||||
.ifBlank {
|
||||
"Voice"
|
||||
}
|
||||
val time =
|
||||
playingVoiceTimeLabel
|
||||
.trim()
|
||||
if (time.isBlank()) sender
|
||||
else "$sender at $time"
|
||||
}
|
||||
// 🔥 Берем dialogs из chatsState для
|
||||
// консистентности
|
||||
// 📌 Порядок по времени готовится в ViewModel.
|
||||
@@ -2332,9 +2485,7 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
top =
|
||||
if (showStickyCallBanner)
|
||||
callBannerHeight
|
||||
else 0.dp
|
||||
stickyTopInset
|
||||
)
|
||||
.then(
|
||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
||||
@@ -2572,6 +2723,18 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val isVoicePlaybackActive by
|
||||
remember(
|
||||
dialog.opponentKey,
|
||||
playingVoiceDialogKey
|
||||
) {
|
||||
derivedStateOf {
|
||||
isVoicePlayingForDialog(
|
||||
dialog.opponentKey,
|
||||
playingVoiceDialogKey
|
||||
)
|
||||
}
|
||||
}
|
||||
val isSelectedDialog =
|
||||
selectedChatKeys
|
||||
.contains(
|
||||
@@ -2613,6 +2776,8 @@ fun ChatsListScreen(
|
||||
typingDisplayName,
|
||||
typingSenderPublicKey =
|
||||
typingSenderPublicKey,
|
||||
isVoicePlaybackActive =
|
||||
isVoicePlaybackActive,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
isSavedMessages =
|
||||
@@ -2746,14 +2911,51 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
isSticky = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenCall = onOpenCallOverlay
|
||||
)
|
||||
if (showStickyCallBanner || showVoiceMiniPlayer) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.align(
|
||||
Alignment.TopCenter
|
||||
)
|
||||
) {
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
isSticky = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenCall = onOpenCallOverlay
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showVoiceMiniPlayer,
|
||||
enter = expandVertically(
|
||||
animationSpec = tween(220, easing = FastOutSlowInEasing),
|
||||
expandFrom = Alignment.Top
|
||||
) + fadeIn(animationSpec = tween(220)),
|
||||
exit = shrinkVertically(
|
||||
animationSpec = tween(260, easing = FastOutSlowInEasing),
|
||||
shrinkTowards = Alignment.Top
|
||||
) + fadeOut(animationSpec = tween(180))
|
||||
) {
|
||||
VoiceTopMiniPlayer(
|
||||
title = voiceMiniPlayerTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isPlaying = isVoicePlaybackRunning,
|
||||
speed = voicePlaybackSpeed,
|
||||
onTogglePlay = {
|
||||
VoicePlaybackCoordinator.toggleCurrentPlayback()
|
||||
},
|
||||
onCycleSpeed = {
|
||||
VoicePlaybackCoordinator.cycleSpeed()
|
||||
},
|
||||
onClose = {
|
||||
VoicePlaybackCoordinator.stop()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3722,6 +3924,7 @@ fun SwipeableDialogItem(
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isVoicePlaybackActive: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isGroupChat: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
@@ -4125,6 +4328,7 @@ fun SwipeableDialogItem(
|
||||
isTyping = isTyping,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey,
|
||||
isVoicePlaybackActive = isVoicePlaybackActive,
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
@@ -4144,6 +4348,7 @@ fun DialogItemContent(
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isVoicePlaybackActive: Boolean = false,
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
@@ -4278,12 +4483,12 @@ fun DialogItemContent(
|
||||
// Name and last message
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppleEmojiText(
|
||||
@@ -4293,7 +4498,8 @@ fun DialogItemContent(
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
if (isGroupDialog) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
@@ -4301,7 +4507,7 @@ fun DialogItemContent(
|
||||
imageVector = TablerIcons.Users,
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp)
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
@@ -4310,7 +4516,7 @@ fun DialogItemContent(
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
size = 16,
|
||||
modifier = Modifier.offset(y = (-2).dp),
|
||||
modifier = Modifier,
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = PrimaryBlue
|
||||
)
|
||||
@@ -4337,6 +4543,7 @@ fun DialogItemContent(
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
@@ -4467,7 +4674,7 @@ fun DialogItemContent(
|
||||
0.6f
|
||||
),
|
||||
modifier =
|
||||
Modifier.size(14.dp)
|
||||
Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
@@ -4487,9 +4694,11 @@ fun DialogItemContent(
|
||||
Text(
|
||||
text = formattedTime,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 13.sp,
|
||||
color =
|
||||
if (visibleUnreadCount > 0) PrimaryBlue
|
||||
else secondaryTextColor
|
||||
else secondaryTextColor,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4506,18 +4715,35 @@ fun DialogItemContent(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
val subtitleMode =
|
||||
remember(
|
||||
isVoicePlaybackActive,
|
||||
isTyping,
|
||||
dialog.draftText
|
||||
) {
|
||||
when {
|
||||
isVoicePlaybackActive -> "voice"
|
||||
isTyping -> "typing"
|
||||
!dialog.draftText.isNullOrEmpty() -> "draft"
|
||||
else -> "message"
|
||||
}
|
||||
}
|
||||
Crossfade(
|
||||
targetState = isTyping,
|
||||
targetState = subtitleMode,
|
||||
animationSpec = tween(150),
|
||||
label = "chatSubtitle"
|
||||
) { showTyping ->
|
||||
if (showTyping) {
|
||||
) { mode ->
|
||||
if (mode == "voice") {
|
||||
VoicePlaybackIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else if (mode == "typing") {
|
||||
TypingIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey
|
||||
)
|
||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||
} else if (mode == "draft") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Draft: ",
|
||||
@@ -4527,7 +4753,7 @@ fun DialogItemContent(
|
||||
maxLines = 1
|
||||
)
|
||||
AppleEmojiText(
|
||||
text = dialog.draftText,
|
||||
text = dialog.draftText.orEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
@@ -4868,6 +5094,167 @@ fun TypingIndicatorSmall(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlaybackIndicatorSmall(
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
|
||||
val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
|
||||
val levels = List(3) { index ->
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 900
|
||||
0f at 0
|
||||
1f at 280
|
||||
0.2f at 580
|
||||
0f at 900
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(index * 130)
|
||||
),
|
||||
label = "voiceBar$index"
|
||||
).value
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
|
||||
val barWidth = 2.dp.toPx()
|
||||
val gap = 2.dp.toPx()
|
||||
val baseY = size.height
|
||||
repeat(3) { index ->
|
||||
val x = index * (barWidth + gap)
|
||||
val progress = levels[index].coerceIn(0f, 1f)
|
||||
val minH = 3.dp.toPx()
|
||||
val maxH = 10.dp.toPx()
|
||||
val height = minH + (maxH - minH) * progress
|
||||
drawRoundRect(
|
||||
color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
|
||||
topLeft = Offset(x, baseY - height),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, height),
|
||||
cornerRadius =
|
||||
androidx.compose.ui.geometry.CornerRadius(
|
||||
x = barWidth,
|
||||
y = barWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
AppleEmojiText(
|
||||
text = "Listening",
|
||||
fontSize = 14.sp,
|
||||
color = accentColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatVoiceSpeedLabel(speed: Float): String {
|
||||
val normalized = (speed * 10f).roundToInt() / 10f
|
||||
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
|
||||
"${normalized.toInt()}x"
|
||||
} else {
|
||||
"${normalized}x"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceTopMiniPlayer(
|
||||
title: String,
|
||||
isDarkTheme: Boolean,
|
||||
isPlaying: Boolean,
|
||||
speed: Float,
|
||||
onTogglePlay: () -> Unit,
|
||||
onCycleSpeed: () -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
// Match overall screen surface aesthetic — neutral elevated surface, no blue accent
|
||||
val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
|
||||
val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||
val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onTogglePlay,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isPlaying) Icons.Default.Pause
|
||||
else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
|
||||
tint = primaryIconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
AppleEmojiText(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.clickable { onCycleSpeed() }
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = formatVoiceSpeedLabel(speed),
|
||||
color = secondaryColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close voice",
|
||||
tint = secondaryColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwipeBackContainer(
|
||||
onBack: () -> Unit,
|
||||
@@ -5467,7 +5854,7 @@ fun DrawerMenuItemEnhanced(
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -5527,7 +5914,7 @@ fun DrawerMenuItemEnhanced(
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -5561,7 +5948,7 @@ fun DrawerMenuItemEnhanced(
|
||||
fun DrawerDivider(isDarkTheme: Boolean) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (groupDescription.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
AppleEmojiText(
|
||||
text = groupDescription,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (groupDescription.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = sectionColor
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
AppleEmojiText(
|
||||
text = groupDescription,
|
||||
color = primaryText,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 8,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Description",
|
||||
color = Color(0xFF8E8E93),
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(
|
||||
color = borderColor,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
color = sectionColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = sectionColor
|
||||
) {
|
||||
Column {
|
||||
// Add Members
|
||||
// Add Members — flat Telegram style, edge-to-edge, white text
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(28.dp))
|
||||
Text(
|
||||
text = "Add Members",
|
||||
color = primaryText,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(groupMenuTrailingIconSize)
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = borderColor,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Encryption Key
|
||||
// Encryption Key — flat Telegram style, edge-to-edge, white text
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(28.dp))
|
||||
Text(
|
||||
text = "Encryption Key",
|
||||
color = primaryText,
|
||||
|
||||
@@ -573,23 +573,23 @@ private fun ChatsTabContent(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recent header (always show with Clear All) ───
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 14.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Recent",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
// ─── Recent header (only when there are recents) ───
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 14.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Recent",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
Text(
|
||||
"Clear All",
|
||||
fontSize = 13.sp,
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.chats.calls
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallMade
|
||||
@@ -34,10 +33,16 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.database.CallHistoryRow
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
@@ -106,16 +111,21 @@ fun CallsHistoryScreen(
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
contentPadding = if (items.isEmpty()) PaddingValues(0.dp) else PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
if (items.isEmpty()) {
|
||||
item(key = "empty_calls") {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No calls yet",
|
||||
subtitle = "Your call history will appear here",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No Calls Yet",
|
||||
subtitle = "Your recent voice and video calls will\nappear here.",
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(items, key = { it.messageId }) { item ->
|
||||
@@ -273,39 +283,63 @@ private fun EmptyCallsState(
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
|
||||
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
||||
val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF242529) else Color(0xFFF6F6FA)
|
||||
val lottieComposition by rememberLottieComposition(
|
||||
LottieCompositionSpec.RawRes(R.raw.phone_duck)
|
||||
)
|
||||
val lottieProgress by animateLottieCompositionAsState(
|
||||
composition = lottieComposition,
|
||||
iterations = 1
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 32.dp),
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(cardColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(34.dp)
|
||||
if (lottieComposition != null) {
|
||||
LottieAnimation(
|
||||
composition = lottieComposition,
|
||||
progress = { lottieProgress },
|
||||
modifier = Modifier.size(184.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = subtitleColor,
|
||||
modifier = Modifier.size(52.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
if (title.isNotBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import android.graphics.Matrix
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
@@ -41,6 +43,8 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
@@ -84,6 +88,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -91,7 +96,10 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
import kotlin.math.PI
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
@@ -141,37 +149,72 @@ private fun decodeBase64Payload(data: String): ByteArray? {
|
||||
private fun decodeHexPayload(data: String): ByteArray? {
|
||||
val raw = data.trim().removePrefix("0x")
|
||||
if (raw.isBlank() || raw.length % 2 != 0) return null
|
||||
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null
|
||||
return runCatching {
|
||||
ByteArray(raw.length / 2) { index ->
|
||||
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
fun nibble(ch: Char): Int =
|
||||
when (ch) {
|
||||
in '0'..'9' -> ch.code - '0'.code
|
||||
in 'a'..'f' -> ch.code - 'a'.code + 10
|
||||
in 'A'..'F' -> ch.code - 'A'.code + 10
|
||||
else -> -1
|
||||
}
|
||||
}.getOrNull()
|
||||
val out = ByteArray(raw.length / 2)
|
||||
var outIndex = 0
|
||||
var index = 0
|
||||
while (index < raw.length) {
|
||||
val hi = nibble(raw[index])
|
||||
val lo = nibble(raw[index + 1])
|
||||
if (hi < 0 || lo < 0) return null
|
||||
out[outIndex++] = ((hi shl 4) or lo).toByte()
|
||||
index += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun decodeVoicePayload(data: String): ByteArray? {
|
||||
return decodeHexPayload(data) ?: decodeBase64Payload(data)
|
||||
}
|
||||
|
||||
private object VoicePlaybackCoordinator {
|
||||
object VoicePlaybackCoordinator {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val speedSteps = listOf(1f, 1.5f, 2f)
|
||||
private var player: MediaPlayer? = null
|
||||
private var currentAttachmentId: String? = null
|
||||
private var progressJob: Job? = null
|
||||
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
||||
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
||||
private val _playingDialogKey = MutableStateFlow<String?>(null)
|
||||
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
|
||||
private val _positionMs = MutableStateFlow(0)
|
||||
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
|
||||
private val _durationMs = MutableStateFlow(0)
|
||||
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
|
||||
private val _playbackSpeed = MutableStateFlow(1f)
|
||||
val playbackSpeed: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
||||
private val _playingSenderLabel = MutableStateFlow("")
|
||||
val playingSenderLabel: StateFlow<String> = _playingSenderLabel.asStateFlow()
|
||||
private val _playingTimeLabel = MutableStateFlow("")
|
||||
val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()
|
||||
|
||||
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) {
|
||||
fun toggle(
|
||||
attachmentId: String,
|
||||
sourceFile: File,
|
||||
dialogKey: String = "",
|
||||
senderLabel: String = "",
|
||||
playedAtLabel: String = "",
|
||||
onError: (String) -> Unit = {}
|
||||
) {
|
||||
if (!sourceFile.exists()) {
|
||||
onError("Voice file is missing")
|
||||
return
|
||||
}
|
||||
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
|
||||
stop()
|
||||
|
||||
if (currentAttachmentId == attachmentId && player != null) {
|
||||
if (_isPlaying.value) {
|
||||
pause()
|
||||
} else {
|
||||
resume(onError = onError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,22 +230,18 @@ private object VoicePlaybackCoordinator {
|
||||
mediaPlayer.setDataSource(sourceFile.absolutePath)
|
||||
mediaPlayer.setOnCompletionListener { stop() }
|
||||
mediaPlayer.prepare()
|
||||
applyPlaybackSpeed(mediaPlayer)
|
||||
mediaPlayer.start()
|
||||
player = mediaPlayer
|
||||
currentAttachmentId = attachmentId
|
||||
_playingAttachmentId.value = attachmentId
|
||||
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
|
||||
_playingSenderLabel.value = senderLabel.trim()
|
||||
_playingTimeLabel.value = playedAtLabel.trim()
|
||||
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
|
||||
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
|
||||
progressJob?.cancel()
|
||||
progressJob =
|
||||
scope.launch {
|
||||
while (isActive && currentAttachmentId == attachmentId) {
|
||||
val active = player
|
||||
if (active == null || !active.isPlaying) break
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
delay(120)
|
||||
}
|
||||
}
|
||||
_isPlaying.value = true
|
||||
startProgressUpdates(attachmentId)
|
||||
} catch (e: Exception) {
|
||||
runCatching { mediaPlayer.release() }
|
||||
stop()
|
||||
@@ -210,6 +249,85 @@ private object VoicePlaybackCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
|
||||
if (player == null || currentAttachmentId.isNullOrBlank()) return
|
||||
if (_isPlaying.value) {
|
||||
pause()
|
||||
} else {
|
||||
resume(onError = onError)
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
val active = player ?: return
|
||||
runCatching {
|
||||
if (active.isPlaying) active.pause()
|
||||
}
|
||||
_isPlaying.value = false
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
fun resume(onError: (String) -> Unit = {}) {
|
||||
val active = player ?: return
|
||||
val attachmentId = currentAttachmentId
|
||||
if (attachmentId.isNullOrBlank()) return
|
||||
try {
|
||||
applyPlaybackSpeed(active)
|
||||
active.start()
|
||||
_durationMs.value = active.duration.coerceAtLeast(0)
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
_isPlaying.value = true
|
||||
startProgressUpdates(attachmentId)
|
||||
} catch (e: Exception) {
|
||||
stop()
|
||||
onError(e.message ?: "Playback failed")
|
||||
}
|
||||
}
|
||||
|
||||
fun cycleSpeed() {
|
||||
val current = _playbackSpeed.value
|
||||
val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
|
||||
val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
|
||||
setPlaybackSpeed(next)
|
||||
}
|
||||
|
||||
private fun setPlaybackSpeed(speed: Float) {
|
||||
val normalized =
|
||||
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
|
||||
_playbackSpeed.value = normalized
|
||||
// Only apply to the player if it's currently playing — otherwise setting
|
||||
// playbackParams auto-resumes a paused MediaPlayer (Android quirk).
|
||||
// The new speed will be applied on the next resume() call.
|
||||
if (_isPlaying.value) {
|
||||
player?.let { applyPlaybackSpeed(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
runCatching {
|
||||
val current = mediaPlayer.playbackParams
|
||||
mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProgressUpdates(attachmentId: String) {
|
||||
progressJob?.cancel()
|
||||
progressJob =
|
||||
scope.launch {
|
||||
while (isActive && currentAttachmentId == attachmentId) {
|
||||
val active = player ?: break
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
_durationMs.value = active.duration.coerceAtLeast(0)
|
||||
if (!active.isPlaying) break
|
||||
delay(120)
|
||||
}
|
||||
_isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val active = player
|
||||
player = null
|
||||
@@ -217,8 +335,12 @@ private object VoicePlaybackCoordinator {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
_playingAttachmentId.value = null
|
||||
_playingDialogKey.value = null
|
||||
_playingSenderLabel.value = ""
|
||||
_playingTimeLabel.value = ""
|
||||
_positionMs.value = 0
|
||||
_durationMs.value = 0
|
||||
_isPlaying.value = false
|
||||
if (active != null) {
|
||||
runCatching {
|
||||
if (active.isPlaying) active.stop()
|
||||
@@ -593,6 +715,7 @@ fun MessageAttachments(
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
senderPublicKey: String,
|
||||
senderDisplayName: String = "",
|
||||
dialogPublicKey: String = "",
|
||||
isGroupChat: Boolean = false,
|
||||
timestamp: java.util.Date,
|
||||
@@ -683,6 +806,8 @@ fun MessageAttachments(
|
||||
chachaKeyPlainHex = chachaKeyPlainHex,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
senderDisplayName = senderDisplayName,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
@@ -1906,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String {
|
||||
return "$minutes:$rem"
|
||||
}
|
||||
|
||||
private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
|
||||
val normalized = kotlin.math.round(speed * 10f) / 10f
|
||||
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
|
||||
"${normalized.toInt()}x"
|
||||
} else {
|
||||
"${normalized}x"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlaybackButtonBlob(
|
||||
level: Float,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val clampedLevel = level.coerceIn(0f, 1f)
|
||||
val animatedLevel by animateFloatAsState(
|
||||
targetValue = clampedLevel,
|
||||
animationSpec = tween(durationMillis = 140),
|
||||
label = "voice_blob_level"
|
||||
)
|
||||
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "voice_blob_pulse"
|
||||
)
|
||||
|
||||
val blobColor =
|
||||
if (isOutgoing) {
|
||||
Color.White
|
||||
} else if (isDarkTheme) {
|
||||
Color(0xFF5DB8FF)
|
||||
} else {
|
||||
PrimaryBlue
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
|
||||
val buttonRadius = 20.dp.toPx() // Play button is 40dp.
|
||||
val amp = animatedLevel.coerceIn(0f, 1f)
|
||||
|
||||
// Telegram-like: soft concentric glow, centered, no geometry distortion.
|
||||
val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
|
||||
val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
|
||||
val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
|
||||
radius = r1,
|
||||
center = center
|
||||
)
|
||||
drawCircle(
|
||||
color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
|
||||
radius = r2,
|
||||
center = center
|
||||
)
|
||||
drawCircle(
|
||||
color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
|
||||
radius = r3,
|
||||
center = center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDesktopCallDuration(durationSec: Int): String {
|
||||
val minutes = durationSec / 60
|
||||
val seconds = durationSec % 60
|
||||
@@ -2022,9 +2217,10 @@ private fun ensureVoiceAudioFile(
|
||||
attachmentId: String,
|
||||
payload: String
|
||||
): File? {
|
||||
val bytes = decodeVoicePayload(payload) ?: return null
|
||||
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
|
||||
val file = File(directory, "$attachmentId.webm")
|
||||
if (file.exists() && file.length() > 0L) return file
|
||||
val bytes = decodeVoicePayload(payload) ?: return null
|
||||
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
|
||||
return file
|
||||
}
|
||||
@@ -2036,6 +2232,8 @@ private fun VoiceAttachment(
|
||||
chachaKeyPlainHex: String,
|
||||
privateKey: String,
|
||||
senderPublicKey: String,
|
||||
senderDisplayName: String,
|
||||
dialogPublicKey: String,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
@@ -2043,10 +2241,21 @@ private fun VoiceAttachment(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
|
||||
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
|
||||
val isPlaying = playingAttachmentId == attachment.id
|
||||
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val isActiveTrack = activeAttachmentId == attachment.id
|
||||
val playbackPositionMs by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0))
|
||||
.collectAsState(initial = 0)
|
||||
val playbackDurationMs by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0))
|
||||
.collectAsState(initial = 0)
|
||||
val playbackIsPlaying by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
|
||||
.collectAsState(initial = false)
|
||||
val playbackSpeed by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
|
||||
.collectAsState(initial = 1f)
|
||||
val isPlaying = isActiveTrack && playbackIsPlaying
|
||||
|
||||
val (previewDurationSecRaw, previewWavesRaw) =
|
||||
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
|
||||
@@ -2060,12 +2269,19 @@ private fun VoiceAttachment(
|
||||
|
||||
var payload by
|
||||
remember(attachment.id, attachment.blob) {
|
||||
mutableStateOf(attachment.blob.trim())
|
||||
mutableStateOf(attachment.blob)
|
||||
}
|
||||
val cachedAudioPath =
|
||||
remember(attachment.id) {
|
||||
val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm")
|
||||
file.takeIf { it.exists() && it.length() > 0L }?.absolutePath
|
||||
}
|
||||
var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) }
|
||||
var downloadStatus by
|
||||
remember(attachment.id, attachment.blob, attachment.transportTag) {
|
||||
mutableStateOf(
|
||||
when {
|
||||
cachedAudioPath != null -> DownloadStatus.DOWNLOADED
|
||||
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
|
||||
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
|
||||
else -> DownloadStatus.ERROR
|
||||
@@ -2073,52 +2289,51 @@ private fun VoiceAttachment(
|
||||
)
|
||||
}
|
||||
var errorText by remember { mutableStateOf("") }
|
||||
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
|
||||
|
||||
val effectiveDurationSec =
|
||||
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
||||
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
|
||||
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||
if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||
}
|
||||
val progress =
|
||||
if (isPlaying && playbackDurationMs > 0) {
|
||||
if (isActiveTrack && playbackDurationMs > 0) {
|
||||
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val liveWaveLevel =
|
||||
remember(isPlaying, progress, waves) {
|
||||
if (!isPlaying || waves.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
val maxIndex = waves.lastIndex.coerceAtLeast(0)
|
||||
val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
|
||||
waves[sampleIndex].coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val timeText =
|
||||
if (isPlaying && playbackDurationMs > 0) {
|
||||
if (isActiveTrack && playbackDurationMs > 0) {
|
||||
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
||||
"-${formatVoiceDuration(leftSec)}"
|
||||
formatVoiceDuration(leftSec)
|
||||
} else {
|
||||
formatVoiceDuration(effectiveDurationSec)
|
||||
}
|
||||
|
||||
LaunchedEffect(payload, attachment.id) {
|
||||
if (payload.isBlank()) return@LaunchedEffect
|
||||
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||
if (prepared != null) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
if (downloadStatus != DownloadStatus.DOWNLOADING &&
|
||||
downloadStatus != DownloadStatus.DECRYPTING
|
||||
) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
val playbackSenderLabel =
|
||||
remember(isOutgoing, senderDisplayName) {
|
||||
val senderName = senderDisplayName.trim()
|
||||
when {
|
||||
isOutgoing -> "You"
|
||||
senderName.isNotBlank() -> senderName
|
||||
else -> "Voice"
|
||||
}
|
||||
}
|
||||
if (errorText.isNotBlank()) errorText = ""
|
||||
} else {
|
||||
audioFilePath = null
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
if (errorText.isBlank()) errorText = "Cannot decode voice"
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(attachment.id) {
|
||||
onDispose {
|
||||
if (playingAttachmentId == attachment.id) {
|
||||
VoicePlaybackCoordinator.stop()
|
||||
val playbackTimeLabel =
|
||||
remember(timestamp.time) {
|
||||
runCatching {
|
||||
SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
|
||||
}
|
||||
.getOrDefault("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val triggerDownload: () -> Unit = download@{
|
||||
if (attachment.transportTag.isBlank()) {
|
||||
@@ -2143,17 +2358,30 @@ private fun VoiceAttachment(
|
||||
errorText = "Failed to decrypt"
|
||||
return@launch
|
||||
}
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
val prepared =
|
||||
withContext(Dispatchers.IO) {
|
||||
ensureVoiceAudioFile(context, attachment.id, decrypted)
|
||||
}
|
||||
if (prepared == null) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = "Cannot decode voice"
|
||||
return@launch
|
||||
}
|
||||
audioFilePath = prepared.absolutePath
|
||||
val saved =
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
.getOrDefault(false)
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
.getOrDefault(false)
|
||||
}
|
||||
payload = decrypted
|
||||
if (!saved) {
|
||||
// Не блокируем UI, но оставляем маркер в логе.
|
||||
@@ -2171,22 +2399,42 @@ private fun VoiceAttachment(
|
||||
val file = audioFilePath?.let { File(it) }
|
||||
if (file == null || !file.exists()) {
|
||||
if (payload.isNotBlank()) {
|
||||
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||
if (prepared != null) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
|
||||
scope.launch {
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
errorText = ""
|
||||
val prepared =
|
||||
withContext(Dispatchers.IO) {
|
||||
ensureVoiceAudioFile(context, attachment.id, payload)
|
||||
}
|
||||
if (prepared != null) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = prepared,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
}
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
errorText = "Cannot decode voice"
|
||||
}
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = "Cannot decode voice"
|
||||
}
|
||||
} else {
|
||||
triggerDownload()
|
||||
}
|
||||
} else {
|
||||
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = file,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
}
|
||||
@@ -2228,12 +2476,29 @@ private fun VoiceAttachment(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(actionBackground),
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
|
||||
VoicePlaybackButtonBlob(
|
||||
level = liveWaveLevel,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier =
|
||||
Modifier.requiredSize(64.dp).graphicsLayer {
|
||||
// Blob lives strictly behind the button; keep button geometry untouched.
|
||||
alpha = 0.96f
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(actionBackground),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING
|
||||
) {
|
||||
@@ -2271,6 +2536,7 @@ private fun VoiceAttachment(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
@@ -2323,6 +2589,38 @@ private fun VoiceAttachment(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (isActiveTrack) {
|
||||
val speedChipBackground =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else if (isDarkTheme) {
|
||||
Color(0xFF31435A)
|
||||
} else {
|
||||
Color(0xFFDCEBFD)
|
||||
}
|
||||
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(speedChipBackground)
|
||||
.clickable(
|
||||
interactionSource =
|
||||
remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
VoicePlaybackCoordinator.cycleSpeed()
|
||||
}
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = speedChipTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -320,6 +320,7 @@ fun TypingIndicator(
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
|
||||
isDarkTheme: Boolean,
|
||||
hasWallpaper: Boolean = false,
|
||||
isSystemSafeChat: Boolean = false,
|
||||
@@ -354,6 +355,16 @@ fun MessageBubble(
|
||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||
contextMenuContent: @Composable () -> Unit = {}
|
||||
) {
|
||||
val isTextSelectionOnThisMessage =
|
||||
remember(
|
||||
textSelectionHelper?.isInSelectionMode,
|
||||
textSelectionHelper?.selectedMessageId,
|
||||
message.id
|
||||
) {
|
||||
textSelectionHelper?.isInSelectionMode == true &&
|
||||
textSelectionHelper.selectedMessageId == message.id
|
||||
}
|
||||
|
||||
// Swipe-to-reply state
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var swipeOffset by remember { mutableStateOf(0f) }
|
||||
@@ -373,7 +384,7 @@ fun MessageBubble(
|
||||
// Selection animations
|
||||
val selectionAlpha by
|
||||
animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.85f else 1f,
|
||||
targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
|
||||
animationSpec = tween(150),
|
||||
label = "selectionAlpha"
|
||||
)
|
||||
@@ -400,6 +411,10 @@ fun MessageBubble(
|
||||
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
||||
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
||||
}
|
||||
var textViewRef by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiTextView?>(null) }
|
||||
val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) {
|
||||
{ textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() }
|
||||
} else null
|
||||
val linksEnabled = !isSelectionMode
|
||||
val textClickHandler: (() -> Unit)? = onClick
|
||||
val mentionClickHandler: ((String) -> Unit)? =
|
||||
@@ -475,8 +490,9 @@ fun MessageBubble(
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
|
||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
|
||||
if (isSystemSafeChat) return@pointerInput
|
||||
if (textSelectionHelper?.isActive == true) return@pointerInput
|
||||
// 🔥 Простой горизонтальный свайп для reply
|
||||
// Используем detectHorizontalDragGestures который лучше работает со
|
||||
// скроллом
|
||||
@@ -552,7 +568,8 @@ fun MessageBubble(
|
||||
val selectionBackgroundColor by
|
||||
animateColorAsState(
|
||||
targetValue =
|
||||
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
|
||||
if (isSelected && !isTextSelectionOnThisMessage)
|
||||
PrimaryBlue.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
animationSpec = tween(200),
|
||||
label = "selectionBg"
|
||||
@@ -718,6 +735,8 @@ fun MessageBubble(
|
||||
message.attachments.all {
|
||||
it.type == AttachmentType.CALL
|
||||
}
|
||||
val hasVoiceAttachment =
|
||||
message.attachments.any { it.type == AttachmentType.VOICE }
|
||||
|
||||
val isStandaloneGroupInvite =
|
||||
message.attachments.isEmpty() &&
|
||||
@@ -857,6 +876,21 @@ fun MessageBubble(
|
||||
if (isCallMessage) {
|
||||
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
|
||||
Modifier
|
||||
} else if (hasVoiceAttachment) {
|
||||
// Для voice не клипуем содержимое пузыря:
|
||||
// playback-blob может выходить за границы, как в Telegram.
|
||||
Modifier.background(
|
||||
color =
|
||||
if (isSafeSystemMessage) {
|
||||
if (isDarkTheme)
|
||||
Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
} else {
|
||||
bubbleColor
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
} else {
|
||||
Modifier.clip(bubbleShape)
|
||||
.then(
|
||||
@@ -998,6 +1032,7 @@ fun MessageBubble(
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
senderPublicKey = senderPublicKey,
|
||||
senderDisplayName = senderName,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isGroupChat = isGroupChat,
|
||||
timestamp = message.timestamp,
|
||||
@@ -1066,7 +1101,32 @@ fun MessageBubble(
|
||||
onClick =
|
||||
textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥 Long press для selection
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||
textSelectionHelper.moveHandle(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
textSelectionHelper.showMagnifier(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
} else null,
|
||||
onSelectionDragEnd = selectionDragEndHandler
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -1157,12 +1217,22 @@ fun MessageBubble(
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
)
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
@@ -1261,11 +1331,32 @@ fun MessageBubble(
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||
textSelectionHelper.moveHandle(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
textSelectionHelper.showMagnifier(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
} else null,
|
||||
onSelectionDragEnd = selectionDragEndHandler
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -2322,6 +2413,8 @@ fun ReplyBubble(
|
||||
)
|
||||
} else if (!hasImage) {
|
||||
val displayText = when {
|
||||
replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||
replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||
else -> "..."
|
||||
@@ -3560,6 +3653,7 @@ fun ProfilePhotoMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
onQrCodeClick: (() -> Unit)? = null,
|
||||
onSetPhotoClick: () -> Unit,
|
||||
onDeletePhotoClick: (() -> Unit)? = null,
|
||||
hasAvatar: Boolean = false
|
||||
@@ -3589,6 +3683,16 @@ fun ProfilePhotoMenu(
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
onQrCodeClick?.let { onQrClick ->
|
||||
ProfilePhotoMenuItem(
|
||||
icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(TablerIcons.Scan),
|
||||
text = "QR Code",
|
||||
onClick = onQrClick,
|
||||
tintColor = iconColor,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
|
||||
ProfilePhotoMenuItem(
|
||||
icon = TelegramIcons.AddPhoto,
|
||||
text = if (hasAvatar) "Set Profile Photo" else "Add Photo",
|
||||
|
||||
@@ -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) {
|
||||
enterOffsetAnimatable.value
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val currentOffset = baseOffset + enterOffset
|
||||
fun computeCurrentOffset(): Float {
|
||||
val base = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.value
|
||||
} else 0f
|
||||
return base + enter
|
||||
}
|
||||
|
||||
// Current alpha: use animatable during fade animations, otherwise 1
|
||||
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
||||
|
||||
// Scrim alpha based on swipe progress
|
||||
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
|
||||
val sharedOwnerId = SwipeBackSharedProgress.ownerId
|
||||
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
||||
val sharedProgress = SwipeBackSharedProgress.progress
|
||||
@@ -239,6 +240,21 @@ fun SwipeBackContainer(
|
||||
}
|
||||
}
|
||||
|
||||
fun forceResetSwipeState() {
|
||||
isDragging = false
|
||||
dragOffset = 0f
|
||||
clearSharedSwipeProgressIfOwner()
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(0f)
|
||||
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.snapTo(0f)
|
||||
}
|
||||
if (shouldShow && !isAnimatingOut) {
|
||||
alphaAnimatable.snapTo(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle visibility changes
|
||||
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
||||
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
|
||||
@@ -292,10 +308,34 @@ fun SwipeBackContainer(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } }
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
if (lifecycleOwner == null) {
|
||||
onDispose { }
|
||||
} else {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
|
||||
|
||||
val currentOffset = computeCurrentOffset()
|
||||
val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
|
||||
val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
@@ -346,13 +386,15 @@ fun SwipeBackContainer(
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var keyboardHiddenForGesture = false
|
||||
var resetOnFinally = true
|
||||
|
||||
// deferToChildren=true: pre-slop uses Main pass so children
|
||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||
// deferToChildren=false (default): always use Initial pass
|
||||
// to intercept before children (original behavior).
|
||||
// Post-claim: always Initial to block children.
|
||||
while (true) {
|
||||
try {
|
||||
while (true) {
|
||||
val pass =
|
||||
if (startedSwipe || !deferToChildren)
|
||||
PointerEventPass.Initial
|
||||
@@ -365,6 +407,7 @@ fun SwipeBackContainer(
|
||||
?: break
|
||||
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
resetOnFinally = false
|
||||
break
|
||||
}
|
||||
|
||||
@@ -443,6 +486,13 @@ fun SwipeBackContainer(
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Сбрасываем только при отмене/прерывании жеста.
|
||||
// При обычном UP сброс делаем позже, чтобы не было рывка.
|
||||
if (resetOnFinally && isDragging) {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
@@ -475,6 +525,32 @@ fun SwipeBackContainer(
|
||||
)
|
||||
|
||||
if (shouldComplete) {
|
||||
// Intercept: if owner handled back locally (e.g. clear
|
||||
// message selection), bounce back without exiting.
|
||||
if (onInterceptSwipeBack()) {
|
||||
dismissKeyboard()
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
ANIMATION_DURATION_EXIT,
|
||||
easing =
|
||||
TelegramEasing
|
||||
),
|
||||
block = {
|
||||
updateSharedSwipeProgress(
|
||||
progress =
|
||||
value /
|
||||
screenWidthPx,
|
||||
active = true
|
||||
)
|
||||
}
|
||||
)
|
||||
dragOffset = 0f
|
||||
clearSharedSwipeProgressIfOwner()
|
||||
return@launch
|
||||
}
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec =
|
||||
|
||||
@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
||||
|
||||
// Auto-switch to matching theme group when app theme changes
|
||||
LaunchedEffect(isDarkTheme) {
|
||||
val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
|
||||
if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
|
||||
// Map to same position in the other group
|
||||
val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||
selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
|
||||
}
|
||||
}
|
||||
// Local dark/light state — independent from the global app theme
|
||||
var localIsDark by remember { mutableStateOf(isDarkTheme) }
|
||||
|
||||
val theme = qrThemes[selectedThemeIndex]
|
||||
|
||||
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
|
||||
color = if (localIsDark) Color(0xFF1C1C1E) else Color.White,
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(TablerIcons.X, contentDescription = "Close",
|
||||
tint = if (isDarkTheme) Color.White else Color.Black)
|
||||
tint = if (localIsDark) Color.White else Color.Black)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (isDarkTheme) Color.White else Color.Black)
|
||||
color = if (localIsDark) Color.White else Color.Black)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Snapshot → toggle theme → circular reveal
|
||||
// Snapshot → toggle LOCAL theme → circular reveal
|
||||
// Does NOT toggle the global app theme
|
||||
val now = System.currentTimeMillis()
|
||||
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
||||
lastRevealTime = now
|
||||
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
|
||||
revealActive = true
|
||||
revealCenter = themeButtonPos
|
||||
revealSnapshot = snapshot.asImageBitmap()
|
||||
// Switch to matching wallpaper in new theme
|
||||
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
|
||||
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||
selectedThemeIndex = newIndex
|
||||
onToggleTheme()
|
||||
localIsDark = !localIsDark
|
||||
scope.launch {
|
||||
try {
|
||||
revealRadius.snapTo(0f)
|
||||
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
|
||||
revealActive = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// drawToBitmap failed — skip
|
||||
}
|
||||
}
|
||||
// else: cooldown active — ignore tap
|
||||
},
|
||||
modifier = Modifier.onGloballyPositioned { coords ->
|
||||
val pos = coords.positionInRoot()
|
||||
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||
contentDescription = "Toggle theme",
|
||||
tint = if (isDarkTheme) Color.White else Color.Black
|
||||
tint = if (localIsDark) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,7 +349,7 @@ fun MyQrCodeScreen(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Wallpaper selector — show current theme's wallpapers
|
||||
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
|
||||
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
|
||||
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
}
|
||||
Icon(TablerIcons.Scan, contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
||||
tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 +1029,31 @@ fun ProfileScreen(
|
||||
x = (-16).dp,
|
||||
y = headerHeight - cameraButtonSize / 2
|
||||
)
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
.graphicsLayer { alpha = floatingButtonsAlpha }
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.AddPhoto,
|
||||
contentDescription = "Change avatar",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
||||
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cameraButtonSize)
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A)
|
||||
else Color(0xFF0D8CF4)
|
||||
)
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.AddPhoto,
|
||||
contentDescription = "Change avatar",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
||||
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1103,6 +1111,7 @@ private fun CollapsingProfileHeader(
|
||||
hasAvatar: Boolean,
|
||||
avatarRepository: AvatarRepository?,
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
onQrCodeClick: () -> Unit = {},
|
||||
onAvatarLongPress: () -> Unit = {}
|
||||
) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
@@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader(
|
||||
expanded = showAvatarMenu,
|
||||
onDismiss = { onAvatarMenuChange(false) },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onQrCodeClick = {
|
||||
onAvatarMenuChange(false)
|
||||
onQrCodeClick()
|
||||
},
|
||||
onSetPhotoClick = {
|
||||
onAvatarMenuChange(false)
|
||||
onSetPhotoClick()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user