Релиз 1.3.5: ForegroundService звонков и фиксы клавиатуры
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
@@ -68,6 +71,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".network.CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone|mediaPlayback" />
|
||||
|
||||
<!-- Firebase notification icon (optional, for better looking notifications) -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
|
||||
@@ -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,17 +778,37 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(callUiState.isVisible) {
|
||||
if (callUiState.isVisible) {
|
||||
val forceHideCallKeyboard: () -> Unit = {
|
||||
focusManager.clearFocus(force = true)
|
||||
|
||||
// Fallback for cases where IME survives focus reset due to window transitions.
|
||||
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())
|
||||
.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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}") }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user