Compare commits

...

10 Commits

Author SHA1 Message Date
434ccef30c Устранены лишние многоточия в статусных сообщениях для исходящих и подключающихся звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m23s
2026-03-29 14:12:13 +05:00
26f4597c3b Релиз 1.3.6: hotfix качества звонков (возврат call-core к 1.3.3)
Some checks failed
Android Kernel Build / build (push) Failing after 12m11s
2026-03-29 13:30:00 +05:00
fa1288479f Релиз 1.3.5: ForegroundService звонков и фиксы клавиатуры
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s
2026-03-28 17:24:08 +05:00
46b1b3a6f1 Релиз 1.3.4: sticky-плашка звонка и поиск сообщений в диалоге
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s
2026-03-28 15:17:58 +05:00
aa40f5287c Релиз 1.3.3: merge dev в master
Some checks failed
Android Kernel Build / build (push) Failing after 11m18s
2026-03-28 13:07:36 +05:00
93a2de315a Слияние dev: экран Calls и обновления звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m41s
2026-03-27 18:22:53 +05:00
0fc637b42a Merge dev into master for release 1.3.2
All checks were successful
Android Kernel Build / build (push) Successful in 20m4s
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
2026-03-27 03:16:20 +05:00
59addf4373 Фикс оптимизации
All checks were successful
Android Kernel Build / build (push) Successful in 17m14s
2026-03-26 03:01:52 +05:00
3953d93207 Фикс error parsing: 1 frame = 1 packet и safe handshake fallback
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:53:21 +05:00
de958e10a1 Стабилизация sync и логов: heartbeat антиспам + Connection Logs через rosettadev2
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:45:16 +05:00
13 changed files with 936 additions and 35 deletions

View File

@@ -1,5 +1,18 @@
# Release Notes
## 1.3.4
### Звонки и UI
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
### Поиск в диалоге
- В kebab-меню каждого чата добавлен пункт `Search`.
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
## 1.3.3
### E2EE, чаты и производительность

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.3"
val rosettaVersionCode = 35 // Increment on each release
val rosettaVersionName = "1.3.6"
val rosettaVersionCode = 38 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -9,6 +9,9 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -68,6 +71,11 @@
</intent-filter>
</service>
<service
android:name=".network.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" />
<!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"

View File

@@ -4,11 +4,13 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -38,6 +40,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
@@ -594,9 +597,13 @@ fun MainScreen(
val rootView = LocalView.current
val callScope = rememberCoroutineScope()
val callUiState by CallManager.state.collectAsState()
var isCallOverlayExpanded by remember { mutableStateOf(true) }
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
var pendingIncomingAccept by remember { mutableStateOf(false) }
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
val isCallScreenVisible =
callUiState.isVisible &&
(isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING)
val mandatoryCallPermissions = remember {
listOf(Manifest.permission.RECORD_AUDIO)
@@ -765,17 +772,54 @@ fun MainScreen(
LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) {
focusManager.clearFocus(force = true)
isCallOverlayExpanded = true
} else {
isCallOverlayExpanded = false
}
}
// Fallback for cases where IME survives focus reset due to window transitions.
val activity = rootView.context as? android.app.Activity
activity?.window?.let { window ->
WindowCompat.getInsetsController(window, rootView)
?.hide(WindowInsetsCompat.Type.ime())
val forceHideCallKeyboard: () -> Unit = {
focusManager.clearFocus(force = true)
rootView.findFocus()?.clearFocus()
rootView.clearFocus()
val activity = rootView.context as? android.app.Activity
activity?.window?.let { window ->
WindowCompat.getInsetsController(window, rootView)
.hide(WindowInsetsCompat.Type.ime())
}
}
DisposableEffect(isCallScreenVisible) {
val activity = rootView.context as? android.app.Activity
val previousSoftInputMode = activity?.window?.attributes?.softInputMode
if (isCallScreenVisible) {
forceHideCallKeyboard()
activity?.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
}
onDispose {
if (isCallScreenVisible && previousSoftInputMode != null) {
activity.window.setSoftInputMode(previousSoftInputMode)
}
}
}
if (isCallScreenVisible) {
SideEffect {
forceHideCallKeyboard()
}
}
// Telegram-style behavior: while call screen is open, Back should minimize call to top banner.
BackHandler(
enabled = callUiState.isVisible &&
isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
) {
isCallOverlayExpanded = false
}
LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
@@ -1044,6 +1088,9 @@ fun MainScreen(
},
chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository,
callUiState = callUiState,
isCallOverlayExpanded = isCallOverlayExpanded,
onOpenCallOverlay = { isCallOverlayExpanded = true },
onAddAccount = {
onAddAccount()
},
@@ -1578,14 +1625,20 @@ fun MainScreen(
}
CallOverlay(
state = callUiState,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() },
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() }
state = callUiState,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() },
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() },
onMinimize = {
if (callUiState.phase != CallPhase.INCOMING) {
isCallOverlayExpanded = false
}
}
)
}
}

View File

@@ -17,12 +17,13 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Оптимизация производительности и стабильности
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
Hotfix звонков
- Исправлен регресс качества аудио в E2EE звонках после 1.3.5
- Возвращена стабильная схема обработки состояния звонка, как в 1.3.3
- Нативный C++ шифратор (XChaCha20/HSalsa20) оставлен без изменений
UI
- Сохранено принудительное скрытие клавиатуры на экране звонка
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -695,6 +695,24 @@ interface MessageSearchIndexDao {
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND dialog_key = :dialogKey
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun searchInDialog(
account: String,
dialogKey: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT m.* FROM messages m

View File

@@ -0,0 +1,341 @@
package com.rosetta.messenger.network
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
/**
* Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/
class CallForegroundService : Service() {
private data class Snapshot(
val phase: CallPhase,
val displayName: String,
val statusText: String,
val durationSec: Int
)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext)
when (action) {
ACTION_STOP -> {
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_END -> {
CallManager.endCall()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_DECLINE -> {
CallManager.declineIncomingCall()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_ACCEPT -> {
val result = CallManager.acceptIncomingCall()
if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) {
openCallUi()
} else {
Log.w(TAG, "Accept action ignored: $result")
}
}
else -> Unit
}
val snapshot = extractSnapshot(intent)
if (snapshot.phase == CallPhase.IDLE) {
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ensureNotificationChannel()
val notification = buildNotification(snapshot)
startForegroundCompat(notification, snapshot.phase)
return START_STICKY
}
private fun extractSnapshot(intent: Intent?): Snapshot {
val state = CallManager.state.value
val payloadIntent = intent
if (payloadIntent == null || !payloadIntent.hasExtra(EXTRA_PHASE)) {
return Snapshot(
phase = state.phase,
displayName = state.displayName,
statusText = state.statusText,
durationSec = state.durationSec
)
}
val rawPhase = payloadIntent.getStringExtra(EXTRA_PHASE).orEmpty()
val phase = runCatching { CallPhase.valueOf(rawPhase) }.getOrElse { state.phase }
val displayName =
payloadIntent.getStringExtra(EXTRA_DISPLAY_NAME)
.orEmpty()
.ifBlank { state.displayName }
.ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
return Snapshot(
phase = phase,
displayName = displayName,
statusText = statusText,
durationSec = durationSec.coerceAtLeast(0)
)
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Ongoing call controls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(snapshot: Snapshot): Notification {
val openAppPendingIntent = PendingIntent.getActivity(
this,
REQUEST_OPEN_APP,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val endCallPendingIntent = PendingIntent.getService(
this,
REQUEST_END_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_END),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val declinePendingIntent = PendingIntent.getService(
this,
REQUEST_DECLINE_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_DECLINE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val answerPendingIntent = PendingIntent.getService(
this,
REQUEST_ACCEPT_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_ACCEPT),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val defaultStatus =
when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call"
CallPhase.OUTGOING -> "Calling"
CallPhase.CONNECTING -> "Connecting"
CallPhase.ACTIVE -> "Call in progress"
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build()
val style =
if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall(
person,
declinePendingIntent,
answerPendingIntent
)
} else {
Notification.CallStyle.forOngoingCall(person, endCallPendingIntent)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_CALL)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.apply {
if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
} else {
addAction(android.R.drawable.ic_menu_close_clear_cancel, "End", endCallPendingIntent)
}
}
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
private fun startForegroundCompat(notification: Notification, phase: CallPhase) {
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val preferredType =
when (phase) {
CallPhase.INCOMING -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.OUTGOING,
CallPhase.CONNECTING,
CallPhase.ACTIVE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.IDLE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
}
startForegroundTyped(notification, preferredType) ||
startForegroundTyped(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) ||
startForegroundUntyped(notification)
} else {
startForegroundUntyped(notification)
}
if (!started) {
Log.e(TAG, "Failed to start foreground service safely; stopping service")
stopSelf()
}
}
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification, type)
true
} catch (error: Throwable) {
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false
}
}
private fun startForegroundUntyped(notification: Notification): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification)
true
} catch (error: Throwable) {
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false
}
}
@Suppress("DEPRECATION")
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
}
companion object {
private const val TAG = "CallForegroundService"
private const val CHANNEL_ID = "rosetta_calls"
private const val NOTIFICATION_ID = 9010
private const val REQUEST_OPEN_APP = 9011
private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
private const val ACTION_DECLINE = "com.rosetta.messenger.call.ACTION_DECLINE"
private const val ACTION_ACCEPT = "com.rosetta.messenger.call.ACTION_ACCEPT"
private const val ACTION_STOP = "com.rosetta.messenger.call.ACTION_STOP"
private const val EXTRA_PHASE = "extra_phase"
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) {
val appContext = context.applicationContext
if (state.phase == CallPhase.IDLE) {
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
return
}
val intent =
Intent(appContext, CallForegroundService::class.java)
.setAction(ACTION_SYNC)
.putExtra(EXTRA_PHASE, state.phase.name)
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error ->
Log.w(TAG, "Failed to start foreground service: ${error.message}")
}
}
fun stop(context: Context) {
val appContext = context.applicationContext
val intent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
runCatching { appContext.startService(intent) }
.onFailure {
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
}
}
}
private fun openCallUi() {
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
runCatching { startActivity(intent) }
.onFailure { error -> Log.w(TAG, "Failed to open call UI: ${error.message}") }
}
}

View File

@@ -359,7 +359,7 @@ object CallManager {
updateState {
it.copy(
phase = CallPhase.CONNECTING,
statusText = "Connecting..."
statusText = "Connecting"
)
}
ensurePeerConnectionAndOffer()

View File

@@ -48,10 +48,12 @@ import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -125,6 +127,27 @@ private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
private enum class ChatHeaderMode {
NORMAL,
SEARCH,
SELECTION
}
private fun buildDialogKeyForSearch(account: String, opponent: String): String {
val normalizedAccount = account.trim()
val normalizedOpponent = opponent.trim()
val normalizedLower = normalizedOpponent.lowercase(Locale.ROOT)
val isGroup =
normalizedLower.startsWith("#group:") || normalizedLower.startsWith("group:")
if (isGroup) return normalizedOpponent
if (normalizedAccount == normalizedOpponent) return normalizedAccount
return if (normalizedAccount < normalizedOpponent) {
"$normalizedAccount:$normalizedOpponent"
} else {
"$normalizedOpponent:$normalizedAccount"
}
}
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar =
java.util.Calendar.getInstance().apply {
@@ -308,6 +331,7 @@ fun ChatDetailScreen(
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val database = RosettaDatabase.getDatabase(context)
val searchIndexDao = remember(database) { database.messageSearchIndexDao() }
val hapticFeedback = LocalHapticFeedback.current
// 🔇 Mute state — read from PreferencesManager
@@ -772,6 +796,18 @@ fun ChatDetailScreen(
var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) }
var isInChatSearchMode by rememberSaveable(user.publicKey) { mutableStateOf(false) }
var inChatSearchQuery by rememberSaveable(user.publicKey) { mutableStateOf("") }
var inChatSearchResultIds by remember(user.publicKey) { mutableStateOf<List<String>>(emptyList()) }
var inChatSearchResultIndex by rememberSaveable(user.publicKey) { mutableIntStateOf(-1) }
val searchFieldFocusRequester = remember(user.publicKey) { FocusRequester() }
val searchDialogKey =
remember(currentUserPublicKey, user.publicKey) {
buildDialogKeyForSearch(
account = currentUserPublicKey,
opponent = user.publicKey
)
}
// Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by
database.blacklistDao()
@@ -1217,6 +1253,90 @@ fun ChatDetailScreen(
}
}
}
val closeInChatSearch: () -> Unit = {
isInChatSearchMode = false
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
hideInputOverlays()
}
val jumpToSearchResult: (Int) -> Unit = jump@{ targetIndex ->
val ids = inChatSearchResultIds
if (ids.isEmpty()) return@jump
val normalizedIndex =
if (targetIndex >= 0) {
targetIndex % ids.size
} else {
((targetIndex % ids.size) + ids.size) % ids.size
}
inChatSearchResultIndex = normalizedIndex
scrollToMessage(ids[normalizedIndex])
}
val searchResultCounterText =
remember(inChatSearchResultIds, inChatSearchResultIndex) {
if (inChatSearchResultIds.isEmpty() || inChatSearchResultIndex < 0) {
"0/0"
} else {
"${inChatSearchResultIndex + 1}/${inChatSearchResultIds.size}"
}
}
LaunchedEffect(isInChatSearchMode) {
if (isInChatSearchMode) {
delay(80)
searchFieldFocusRequester.requestFocus()
} else {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
}
LaunchedEffect(isSelectionMode) {
if (isSelectionMode && isInChatSearchMode) {
closeInChatSearch()
}
}
LaunchedEffect(
isInChatSearchMode,
inChatSearchQuery,
currentUserPublicKey,
searchDialogKey
) {
if (!isInChatSearchMode) return@LaunchedEffect
val account = currentUserPublicKey.trim()
val normalizedQuery = inChatSearchQuery.trim().lowercase(Locale.ROOT)
if (account.isBlank() || normalizedQuery.length < 2) {
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
return@LaunchedEffect
}
delay(250)
val resultIds =
withContext(Dispatchers.IO) {
searchIndexDao
.searchInDialog(
account = account,
dialogKey = searchDialogKey,
queryNormalized = normalizedQuery,
limit = 200,
offset = 0
)
.map { it.messageId }
.distinct()
}
inChatSearchResultIds = resultIds
if (resultIds.isEmpty()) {
inChatSearchResultIndex = -1
} else {
inChatSearchResultIndex = 0
scrollToMessage(resultIds.first())
}
}
// Динамический subtitle: typing > online > offline
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
@@ -1241,7 +1361,13 @@ fun ChatDetailScreen(
}
// 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() }
BackHandler {
if (isInChatSearchMode) {
closeInChatSearch()
} else {
hideKeyboardAndBack()
}
}
// 🔥 Lifecycle-aware отслеживание активности экрана
val lifecycleOwner = LocalLifecycleOwner.current
@@ -1433,12 +1559,18 @@ fun ChatDetailScreen(
) {
// Контент хедера с Crossfade для плавной смены - ускоренная
// анимация
val headerMode =
when {
isSelectionMode -> ChatHeaderMode.SELECTION
isInChatSearchMode -> ChatHeaderMode.SEARCH
else -> ChatHeaderMode.NORMAL
}
Crossfade(
targetState = isSelectionMode,
targetState = headerMode,
animationSpec = tween(150),
label = "headerContent"
) { selectionMode ->
if (selectionMode) {
) { currentHeaderMode ->
if (currentHeaderMode == ChatHeaderMode.SELECTION) {
// SELECTION MODE CONTENT
Row(
modifier =
@@ -1601,6 +1733,129 @@ fun ChatDetailScreen(
}
}
}
} else if (currentHeaderMode == ChatHeaderMode.SEARCH) {
// SEARCH MODE CONTENT
Row(
modifier =
Modifier.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { closeInChatSearch() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Close search",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
TextField(
value = inChatSearchQuery,
onValueChange = { value ->
inChatSearchQuery = value
},
singleLine = true,
modifier =
Modifier.weight(1f)
.focusRequester(searchFieldFocusRequester),
textStyle =
LocalTextStyle.current.copy(
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
),
placeholder = {
Text(
text = "Search in chat",
color = Color.White.copy(alpha = 0.65f),
fontSize = 16.sp
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f)
)
},
trailingIcon = {
if (inChatSearchQuery.isNotBlank()) {
IconButton(
onClick = {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = Color.White.copy(alpha = 0.85f)
)
}
}
},
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White
)
)
Text(
text = searchResultCounterText,
color = Color.White.copy(alpha = 0.78f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 4.dp)
)
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex - 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronUp,
contentDescription = "Previous result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex + 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Next result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
}
} else {
// NORMAL HEADER CONTENT
Row(
@@ -1943,6 +2198,14 @@ fun ChatDetailScreen(
isSystemAccount,
isBlocked =
isBlocked,
onSearchInChatClick = {
showMenu = false
hideInputOverlays()
isInChatSearchMode = true
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
},
onGroupInfoClick = {
showMenu =
false

View File

@@ -64,11 +64,14 @@ import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
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.components.AppleEmojiText
@@ -268,6 +271,9 @@ fun ChatsListScreen(
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
callUiState: CallUiState = CallUiState(),
isCallOverlayExpanded: Boolean = true,
onOpenCallOverlay: () -> Unit = {},
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
@@ -2095,6 +2101,13 @@ fun ChatsListScreen(
Color(0xFF1A1A1A)
else Color(0xFFF2F2F7)
}
val showStickyCallBanner =
remember(callUiState, isCallOverlayExpanded) {
callUiState.isVisible &&
!isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
}
val callBannerHeight = 40.dp
// 🔥 Берем dialogs из chatsState для
// консистентности
// 📌 Порядок по времени готовится в ViewModel.
@@ -2286,10 +2299,21 @@ fun ChatsListScreen(
}
}
Box(
modifier =
Modifier.fillMaxSize()
.background(listBackgroundColor)
) {
LazyColumn(
state = chatListState,
modifier =
Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
)
.then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
else Modifier
@@ -2627,6 +2651,16 @@ fun ChatsListScreen(
}
}
}
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
}
}
}
} // Close Requests AnimatedContent
} // Close calls/main switch

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -45,6 +46,8 @@ import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown
// ── Telegram-style dark gradient colors ──────────────────────────
@@ -66,11 +69,13 @@ fun CallOverlay(
state: CallUiState,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null,
isExpanded: Boolean = true,
onAccept: () -> Unit,
onDecline: () -> Unit,
onEnd: () -> Unit,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit
onToggleSpeaker: () -> Unit,
onMinimize: (() -> Unit)? = null
) {
val view = LocalView.current
LaunchedEffect(state.isVisible) {
@@ -85,7 +90,7 @@ fun CallOverlay(
}
AnimatedVisibility(
visible = state.isVisible,
visible = state.isVisible && isExpanded,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
@@ -96,17 +101,43 @@ fun CallOverlay(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) {
// ── Top-right QR icon ──
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
Box(
// ── Top controls: minimize (left) + key cast QR (right) ──
val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
val showKeyCast =
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
state.keyCast.isNotBlank()
if (canMinimize || showKeyCast) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterEnd
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
EncryptionKeyButton(keyHex = state.keyCast)
if (canMinimize) {
IconButton(
onClick = { onMinimize?.invoke() },
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Minimize call",
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
if (showKeyCast) {
EncryptionKeyButton(keyHex = state.keyCast)
} else {
Spacer(modifier = Modifier.size(48.dp))
}
}
}

View File

@@ -0,0 +1,129 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
private val TelegramCallGreenStart = Color(0xFF3FD17F)
private val TelegramCallGreenEnd = Color(0xFF27C4AE)
@Composable
fun CallTopBanner(
state: CallUiState,
onOpenCall: () -> Unit,
isSticky: Boolean = false,
isDarkTheme: Boolean = true,
avatarRepository: AvatarRepository? = null
) {
AnimatedVisibility(
visible = state.isVisible && state.phase != CallPhase.INCOMING,
enter = slideInVertically(initialOffsetY = { -it / 2 }, animationSpec = tween(220)) +
fadeIn(animationSpec = tween(220)),
exit = slideOutVertically(targetOffsetY = { -it / 2 }, animationSpec = tween(180)) +
fadeOut(animationSpec = tween(160))
) {
val bannerModifier =
if (isSticky) {
Modifier
.fillMaxWidth()
.height(42.dp)
} else {
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 10.dp, vertical = 8.dp)
.height(42.dp)
.clip(RoundedCornerShape(12.dp))
}
Row(
modifier = bannerModifier
.background(
Brush.horizontalGradient(
listOf(TelegramCallGreenStart, TelegramCallGreenEnd)
)
)
.clickable { onOpenCall() }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.offset(x = if (isSticky) (-4).dp else 0.dp)) {
AvatarImage(
publicKey = state.peerPublicKey,
avatarRepository = avatarRepository,
size = 24.dp,
isDarkTheme = isDarkTheme,
showOnlineIndicator = false,
displayName = state.displayName
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = buildBannerText(state),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
modifier =
Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Voice call",
tint = Color.White,
modifier = Modifier.size(17.dp)
)
}
}
}
}
private fun buildBannerText(state: CallUiState): String {
return state.displayName
}

View File

@@ -3245,6 +3245,7 @@ fun KebabMenu(
isGroupChat: Boolean = false,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onSearchInChatClick: () -> Unit = {},
onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {},
@@ -3276,7 +3277,16 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search",
onClick = onSearchInChatClick,
tintColor = iconColor,
textColor = textColor
)
if (isGroupChat) {
Divider(color = dividerColor)
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search Members",