diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 47bfa5e..9a1e219 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
-val rosettaVersionName = "1.3.4"
-val rosettaVersionCode = 36 // Increment on each release
+val rosettaVersionName = "1.3.5"
+val rosettaVersionCode = 37 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8c35235..b2dea42 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,9 @@
+
+
+
@@ -67,6 +70,11 @@
+
+
(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)
@@ -774,19 +778,39 @@ fun MainScreen(
}
}
- LaunchedEffect(callUiState.isVisible) {
- if (callUiState.isVisible) {
- focusManager.clearFocus(force = true)
+ 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())
+ }
+ }
- // 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())
+ 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 &&
diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
index 24fddd5..ea11842 100644
--- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
@@ -17,15 +17,13 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- Звонки и навигация
- - Звонок можно свернуть и продолжить в фоне приложения с закрепленной верхней плашкой в чат-листе (Telegram-style)
- - Обновлен экран звонка: кнопка сворачивания в стиле Telegram, улучшено поведение overlay
- - Исправлено скрытие клавиатуры при открытии звонка
+ Звонки
+ - Добавлен Foreground Service для звонков с системной call-нотификацией
+ - Поддержаны действия из нотификации: Answer / Decline / End
+ - Исправлен краш на Android 14+ при запуске FGS (убран недоступный phoneCall type, добавлены безопасные fallback-режимы старта сервиса)
- Поиск в диалоге
- - В kebab-меню чата добавлен пункт Search
- - Реализован поиск сообщений внутри текущего диалога (локально по индексу message_search_index)
- - Добавлена навигация по результатам поиска (предыдущий/следующий) с автопереходом к сообщению
+ UI звонка
+ - Клавиатура принудительно скрывается на экране звонка и не появляется поверх call overlay
""".trimIndent()
fun getNotice(version: String): String =
diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt
new file mode 100644
index 0000000..795f2f9
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt
@@ -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}") }
+ }
+}
diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
index bdca1f4..e9d528c 100644
--- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
@@ -150,6 +150,7 @@ object CallManager {
if (initialized) return
initialized = true
appContext = context.applicationContext
+ syncForegroundService()
CallSoundManager.initialize(context)
XChaCha20E2EE.initWithContext(context)
@@ -835,6 +836,7 @@ object CallManager {
disconnectResetJob = null
setSpeakerphone(false)
_state.value = CallUiState()
+ syncForegroundService()
}
private fun resetRtcObjects() {
@@ -1255,6 +1257,12 @@ object CallManager {
private fun updateState(reducer: (CallUiState) -> CallUiState) {
_state.update(reducer)
+ syncForegroundService()
+ }
+
+ private fun syncForegroundService() {
+ val context = appContext ?: return
+ CallForegroundService.syncWithCallState(context, _state.value)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }