Compare commits
10 Commits
b271917594
...
434ccef30c
| Author | SHA1 | Date | |
|---|---|---|---|
| 434ccef30c | |||
| 26f4597c3b | |||
| fa1288479f | |||
| 46b1b3a6f1 | |||
| aa40f5287c | |||
| 93a2de315a | |||
| 0fc637b42a | |||
| 59addf4373 | |||
| 3953d93207 | |||
| de958e10a1 |
@@ -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, чаты и производительность
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
@@ -67,6 +70,11 @@
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}") }
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ object CallManager {
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.CONNECTING,
|
||||
statusText = "Connecting..."
|
||||
statusText = "Connecting"
|
||||
)
|
||||
}
|
||||
ensurePeerConnectionAndOffer()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user