From fa1288479f7b0ab26be9733143b34ac61937dd56 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 28 Mar 2026 17:24:08 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.3.5:=20Foreg?= =?UTF-8?q?roundService=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 8 + .../com/rosetta/messenger/MainActivity.kt | 40 +- .../rosetta/messenger/data/ReleaseNotes.kt | 14 +- .../network/CallForegroundService.kt | 341 ++++++++++++++++++ .../rosetta/messenger/network/CallManager.kt | 8 + 6 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt 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) }