Релиз 1.3.5: ForegroundService звонков и фиксы клавиатуры
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s

This commit is contained in:
2026-03-28 17:24:08 +05:00
parent 46b1b3a6f1
commit fa1288479f
6 changed files with 397 additions and 18 deletions

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.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 {

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" />
@@ -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

View File

@@ -4,6 +4,7 @@ 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
@@ -600,6 +601,9 @@ fun MainScreen(
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)
@@ -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 &&

View File

@@ -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 =

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

@@ -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) }