Релиз 1.4.3: полноэкранные входящие звонки, аватарки в уведомлениях, фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 4m6s

Звонки:
- IncomingCallActivity — полноэкранный UI входящего звонка поверх lock screen
- fullScreenIntent на нотификации для Android 12+
- ForegroundService синхронизируется при смене фазы и имени
- Запрос fullScreenIntent permission на Android 14+
- dispose() PeerConnection при завершении звонка
- Защита от CREATE_ROOM без ключей (звонок на другом устройстве)
- Дедупликация push + WebSocket сигналов
- setIncomingFromPush — CallManager сразу в INCOMING по push
- Accept ждёт до 5 сек если WebSocket не доставил сигнал
- Decline работает во всех фазах (не только INCOMING)
- Баннер активного звонка внутри диалога

Уведомления:
- Аватарки и имена по publicKey в уведомлениях (message + call)
- Настройка "Avatars in Notifications" в разделе Notifications

UI:
- Ограничение fontScale до 1.3x (вёрстка не ломается на огромном тексте)
- Новые обои: Light 1-3 для светлой темы, убраны старые back_*
- ContentScale.Crop для превью обоев (без растяжения)

CI/CD:
- NDK/CMake в CI, local.properties, ANDROID_NDK_HOME
- Ограничение JVM heap для CI раннера

Диагностика:
- Логирование call notification flow в crash_reports (rosettadev1)
- FCM токен в crash_reports
This commit is contained in:
2026-04-02 01:18:20 +05:00
parent 803fda9abe
commit 876c1ab4df
30 changed files with 789 additions and 170 deletions

View File

@@ -72,6 +72,7 @@ jobs:
"cmake;3.22.1"
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> $GITHUB_ENV
- name: Cache Gradle wrapper
uses: actions/cache@v3
@@ -112,8 +113,15 @@ jobs:
./gradlew --no-daemon --version
- name: Configure local.properties
run: |
echo "sdk.dir=$ANDROID_HOME" > local.properties
echo "ndk.dir=$ANDROID_HOME/ndk/26.1.10909125" >> local.properties
echo "cmake.dir=$ANDROID_HOME/cmake/3.22.1" >> local.properties
cat local.properties
- name: Build Release APK
run: ./gradlew --no-daemon assembleRelease
run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease
- name: Check if APK exists
run: |

View File

@@ -1,5 +1,26 @@
# Release Notes
## 1.4.2
### Звонки
- Полноэкранный incoming call через ForegroundService — кнопки Accept/Decline, будит экран, работает когда приложение свёрнуто или убито (и из push, и из WebSocket).
- Синхронизация ForegroundService с фазами звонка — notification обновляется при INCOMING → CONNECTING → ACTIVE → IDLE.
- Защита от CREATE_ROOM без ключей шифрования — сброс сессии если звонок принят на другом устройстве.
- Корректное освобождение PeerConnection (`dispose()`) при завершении звонка — фикс зависания ICE портов ~30 сек.
### E2EE диагностика
- Диагностический файл E2EE включён для всех билдов (был только debug).
- Периодический health-лог E2EE с счётчиками фреймов enc/dec из нативного кода.
- Уменьшен спам scan receivers — логирование только при изменении состояния.
- Нативные методы `FrameCount()` / `BadStreak()` для мониторинга шифрования в реальном времени.
### Push-уведомления
- Добавлены `tokenType` и `deviceId` в пакет push-подписки (совместимость с новым сервером).
- Сохранение FCM токена в crash_reports для просмотра через rosettadev1.
### CI/CD
- Установка NDK и CMake в CI для сборки нативного модуля `rosetta_e2ee.so`.
## 1.3.4
### Звонки и UI

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.2"
val rosettaVersionCode = 44 // Increment on each release
val rosettaVersionName = "1.4.3"
val rosettaVersionCode = 45 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -10,8 +10,11 @@
<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.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<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.FOREGROUND_SERVICE_PHONE_CALL" />
<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" />
@@ -43,14 +46,27 @@
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
android:screenOrientation="portrait"
android:showWhenLocked="true"
android:turnScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".IncomingCallActivity"
android:exported="false"
android:theme="@style/Theme.RosettaAndroid"
android:launchMode="singleTask"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:screenOrientation="portrait"
android:excludeFromRecents="true"
android:taskAffinity="com.rosetta.messenger.call" />
<!-- FileProvider for camera images -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -74,7 +90,7 @@
<service
android:name=".network.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" />
android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
<!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data

View File

@@ -0,0 +1,164 @@
package com.rosetta.messenger
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.ui.chats.calls.CallOverlay
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
/**
* Лёгкая Activity для показа входящего звонка на lock screen.
* Показывается поверх экрана блокировки, без auth/splash.
* При Accept → переходит в MainActivity. При Decline → закрывается.
*/
class IncomingCallActivity : ComponentActivity() {
companion object {
private const val TAG = "IncomingCallActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
try {
super.onCreate(savedInstanceState)
} catch (e: Throwable) {
Log.e(TAG, "super.onCreate CRASHED", e)
callLog("super.onCreate CRASHED: ${e.message}")
finish()
return
}
callLog("onCreate START")
// Показываем поверх lock screen и включаем экран
callLog("setting lock screen flags, SDK=${Build.VERSION.SDK_INT}")
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
)
}
// Dismiss keyguard
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
km?.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
}
try {
CallManager.initialize(applicationContext)
callLog("CallManager initialized, phase=${CallManager.state.value.phase}")
} catch (e: Throwable) {
callLog("CallManager.initialize CRASHED: ${e.message}")
Log.e(TAG, "CallManager init failed", e)
}
callLog("calling setContent")
setContent {
val callState by CallManager.state.collectAsState()
// Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE)
var wasIncoming by remember { mutableStateOf(false) }
LaunchedEffect(callState.phase) {
callLog("phase changed: ${callState.phase}")
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
// Закрываем только если звонок реально начался и потом завершился
if (callState.phase == CallPhase.IDLE && wasIncoming) {
callLog("IDLE after INCOMING → finish()")
finish()
} else if (callState.phase == CallPhase.CONNECTING ||
callState.phase == CallPhase.ACTIVE) {
callLog("${callState.phase} → openMainActivity + finish")
openMainActivity()
finish()
}
}
// Показываем INCOMING даже если CallManager ещё в IDLE (push раньше WebSocket)
val displayState = if (callState.phase == CallPhase.IDLE) {
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
} else callState
RosettaAndroidTheme(darkTheme = true) {
CallOverlay(
state = displayState,
isDarkTheme = true,
isExpanded = true,
onAccept = {
callLog("onAccept tapped, phase=${callState.phase}")
if (callState.phase == CallPhase.INCOMING) {
val result = CallManager.acceptIncomingCall()
callLog("acceptIncomingCall result=$result")
if (result == CallActionResult.STARTED) {
openMainActivity()
finish()
}
} else {
callLog("onAccept: phase not INCOMING yet, waiting...")
// WebSocket ещё не доставил CALL — открываем MainActivity,
// она подождёт и примет звонок
openMainActivity()
finish()
}
},
onDecline = {
callLog("onDecline tapped")
CallManager.declineIncomingCall()
finish()
},
onEnd = {
callLog("onEnd tapped")
CallManager.endCall()
finish()
},
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() }
)
}
}
}
private fun openMainActivity() {
callLog("openMainActivity")
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(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
startActivity(intent)
}
private fun callLog(msg: String) {
Log.d(TAG, msg)
try {
val ctx = applicationContext ?: return
val dir = java.io.File(ctx.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, "call_notification_log.txt")
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
f.appendText("$ts [IncomingCallActivity] $msg\n")
} catch (e: Throwable) {
Log.e(TAG, "callLog write failed: ${e.message}")
}
}
}

View File

@@ -1,6 +1,8 @@
package com.rosetta.messenger
// commit
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -83,6 +85,10 @@ class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager
// Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth
// mutableStateOf чтобы Compose реагировал на изменение (избежать race condition)
private var openedForCall by mutableStateOf(false)
companion object {
private const val TAG = "MainActivity"
// Process-memory session cache: lets app return without password while process is alive.
@@ -120,6 +126,7 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleCallLockScreen(intent)
preferencesManager = PreferencesManager(this)
accountManager = AccountManager(this)
@@ -161,6 +168,21 @@ class MainActivity : FragmentActivity() {
)
}
}
// Android 14+: запрос fullScreenIntent для входящих звонков
if (Build.VERSION.SDK_INT >= 34) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
if (!nm.canUseFullScreenIntent()) {
try {
startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
android.net.Uri.parse("package:$packageName")
)
)
} catch (_: Throwable) {}
}
}
}
val scope = rememberCoroutineScope()
@@ -212,6 +234,9 @@ class MainActivity : FragmentActivity() {
showSplash -> "splash"
showOnboarding && hasExistingAccount == false ->
"onboarding"
// При открытии по звонку с lock screen — пропускаем auth
openedForCall && hasExistingAccount == true ->
"main"
isLoggedIn != true && hasExistingAccount == false ->
"auth_new"
isLoggedIn != true && hasExistingAccount == true ->
@@ -433,6 +458,66 @@ class MainActivity : FragmentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleCallLockScreen(intent)
}
private var callLockScreenJob: kotlinx.coroutines.Job? = null
/**
* Показать Activity поверх экрана блокировки при входящем звонке.
* При завершении звонка флаги снимаются чтобы не нарушать обычное поведение.
*/
private fun handleCallLockScreen(intent: Intent?) {
val isCallIntent = intent?.getBooleanExtra(
com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false
) == true
if (isCallIntent) {
openedForCall = true
// Включаем экран и показываем поверх lock screen
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
// Убираем lock screen полностью
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager
keyguardManager?.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
}
// Снять флаги когда звонок закончится (отменяем предыдущий коллектор если был)
callLockScreenJob?.cancel()
callLockScreenJob = lifecycleScope.launch {
com.rosetta.messenger.network.CallManager.state.collect { state ->
if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) {
openedForCall = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
callLockScreenJob?.cancel()
callLockScreenJob = null
}
}
}
}
}
override fun onResume() {
super.onResume()
// 🔥 Приложение стало видимым - отключаем уведомления
@@ -1347,7 +1432,8 @@ fun MainScreen(
chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
isCallActive = callUiState.isVisible
isCallActive = callUiState.isVisible,
onOpenCallOverlay = { isCallOverlayExpanded = true }
)
}
}

View File

@@ -36,6 +36,7 @@ class PreferencesManager(private val context: Context) {
val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
// Chat Settings
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large
@@ -143,6 +144,11 @@ class PreferencesManager(private val context: Context) {
preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
}
val notificationAvatarEnabled: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value }
}
@@ -159,6 +165,10 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value }
}
suspend fun setNotificationAvatarEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATION_AVATAR_ENABLED] = value }
}
// ═════════════════════════════════════════════════════════════
// 💬 CHAT SETTINGS
// ═════════════════════════════════════════════════════════════

View File

@@ -17,15 +17,16 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Протокол и вложения
- Обновлен Stream под новый серверный формат сериализации
- Добавлена поддержка transportServer/transportTag во вложениях
- Исправлена совместимость шифрования вложений Android -> Desktop
- Улучшена обработка call-аттачментов и рендер карточек звонков
Звонки
- Полноэкранный входящий звонок с Accept/Decline даже когда приложение свёрнуто или убито
- Исправлен сброс PeerConnection — больше нет зависания ~30 сек между звонками
- Защита от фантомных звонков при принятии на другом устройстве
E2EE
- Улучшена диагностика шифрования звонков
Push-уведомления
- Пуши теперь учитывают mute-чаты корректно
- Заголовок уведомления берет имя отправителя из payload сервера
- Поддержка tokenType и deviceId для новых серверов
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -9,13 +9,26 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.os.Build
import android.os.IBinder
import android.util.Base64
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Keeps call alive while app goes to background.
@@ -35,38 +48,72 @@ class CallForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext)
notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}")
when (action) {
ACTION_STOP -> {
notifLog("ACTION_STOP → stopSelf")
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_END -> {
notifLog("ACTION_END → endCall")
CallManager.endCall()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_DECLINE -> {
CallManager.declineIncomingCall()
val phase = CallManager.state.value.phase
notifLog("ACTION_DECLINE phase=$phase")
if (phase == CallPhase.INCOMING) {
CallManager.declineIncomingCall()
} else {
// Если звонок уже не в INCOMING (CONNECTING/ACTIVE) — endCall
CallManager.endCall()
}
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")
notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}")
// Если push пришёл раньше WebSocket — CallManager ещё в IDLE.
// Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket.
CoroutineScope(Dispatchers.Main).launch {
var accepted = false
for (i in 1..50) { // 50 * 100ms = 5 sec
val phase = CallManager.state.value.phase
if (phase == CallPhase.INCOMING) {
val result = CallManager.acceptIncomingCall()
notifLog("ACTION_ACCEPT attempt #$i result=$result")
if (result == CallActionResult.STARTED) {
openCallUi()
notifLog("ACTION_ACCEPT → openCallUi()")
accepted = true
}
break
} else if (phase != CallPhase.IDLE) {
notifLog("ACTION_ACCEPT phase=$phase (not INCOMING/IDLE), opening UI")
openCallUi()
accepted = true
break
}
delay(100)
}
if (!accepted) {
notifLog("ACTION_ACCEPT: timed out waiting for INCOMING, phase=${CallManager.state.value.phase}")
}
}
}
else -> Unit
}
val snapshot = extractSnapshot(intent)
notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}")
if (snapshot.phase == CallPhase.IDLE) {
notifLog("phase=IDLE → stopSelf")
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
@@ -74,7 +121,18 @@ class CallForegroundService : Service() {
ensureNotificationChannel()
val notification = buildNotification(snapshot)
val hasFullScreen = snapshot.phase == CallPhase.INCOMING
notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground")
startForegroundCompat(notification, snapshot.phase)
notifLog("startForeground OK, phase=${snapshot.phase}")
// Проверяем canUseFullScreenIntent на Android 14+
if (Build.VERSION.SDK_INT >= 34) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
val canFsi = nm.canUseFullScreenIntent()
notifLog("Android 14+: canUseFullScreenIntent=$canFsi")
}
return START_STICKY
}
@@ -110,29 +168,40 @@ class CallForegroundService : Service() {
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
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
NotificationManager.IMPORTANCE_HIGH
NotificationManager.IMPORTANCE_MAX
).apply {
description = "Ongoing call controls"
description = "Incoming and ongoing calls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false)
enableVibration(true)
vibrationPattern = longArrayOf(0, 1000, 500, 1000)
setBypassDnd(true)
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(snapshot: Snapshot): Notification {
// При INCOMING — нажатие открывает IncomingCallActivity (полноэкранный звонок)
// При остальных фазах — открывает MainActivity
val contentActivity = if (snapshot.phase == CallPhase.INCOMING) {
com.rosetta.messenger.IncomingCallActivity::class.java
} else {
MainActivity::class.java
}
val openAppPendingIntent = PendingIntent.getActivity(
this,
REQUEST_OPEN_APP,
Intent(this, MainActivity::class.java).apply {
Intent(this, contentActivity).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)
if (contentActivity == MainActivity::class.java) {
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -155,6 +224,18 @@ class CallForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// fullScreenIntent открывает лёгкую IncomingCallActivity поверх lock screen
val fullScreenPendingIntent = if (snapshot.phase == CallPhase.INCOMING) {
PendingIntent.getActivity(
this,
REQUEST_FULL_SCREEN,
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else null
val defaultStatus =
when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call"
@@ -164,9 +245,14 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build()
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
if (avatarBitmap != null) {
personBuilder.setIcon(Icon.createWithBitmap(avatarBitmap))
}
val person = personBuilder.build()
val style =
if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall(
@@ -188,6 +274,11 @@ class CallForegroundService : Service() {
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
@@ -200,6 +291,7 @@ class CallForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) }
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -207,6 +299,11 @@ class CallForegroundService : Service() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply {
if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
@@ -251,8 +348,10 @@ class CallForegroundService : Service() {
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification, type)
notifLog("startForeground OK type=$type")
true
} catch (error: Throwable) {
notifLog("startForeground FAILED type=$type: ${error.message}")
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false
}
@@ -261,8 +360,10 @@ class CallForegroundService : Service() {
private fun startForegroundUntyped(notification: Notification): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification)
notifLog("startForeground (untyped) OK")
true
} catch (error: Throwable) {
notifLog("startForeground (untyped) FAILED: ${error.message}")
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false
}
@@ -285,6 +386,8 @@ class CallForegroundService : Service() {
private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014
private const val REQUEST_FULL_SCREEN = 9015
private const val NOTIF_LOG_FILE = "call_notification_log.txt"
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
@@ -329,13 +432,82 @@ class CallForegroundService : Service() {
}
}
private fun loadAvatarBitmap(publicKey: String): Bitmap? {
if (publicKey.isBlank()) return null
// Проверяем настройку
val avatarEnabled = runCatching {
runBlocking(Dispatchers.IO) {
com.rosetta.messenger.data.PreferencesManager(applicationContext)
.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
if (!avatarEnabled) return null
return runCatching {
val db = RosettaDatabase.getDatabase(applicationContext)
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null
val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
toCircleBitmap(original)
}.getOrNull()
}
private fun toCircleBitmap(source: Bitmap): Bitmap {
val size = minOf(source.width, source.height)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(output)
val paint = android.graphics.Paint().apply { isAntiAlias = true }
val rect = android.graphics.Rect(0, 0, size, size)
canvas.drawARGB(0, 0, 0, 0)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
canvas.drawBitmap(source, rect, rect, paint)
return output
}
private fun openCallUi() {
notifLog("openCallUi → MainActivity")
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}") }
.onSuccess { notifLog("openCallUi → started OK") }
.onFailure { error ->
notifLog("openCallUi FAILED: ${error.message}")
Log.w(TAG, "Failed to open call UI: ${error.message}")
}
}
private fun openIncomingCallUi() {
notifLog("openIncomingCallUi → IncomingCallActivity")
val intent =
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
runCatching { startActivity(intent) }
.onSuccess { notifLog("openIncomingCallUi → started OK") }
.onFailure { error ->
notifLog("openIncomingCallUi FAILED: ${error.message}")
Log.w(TAG, "Failed to open incoming call UI: ${error.message}")
}
}
/** Пишет лог в crash_reports/call_notification_log.txt — виден через rosettadev1 */
private fun notifLog(msg: String) {
Log.d(TAG, msg)
try {
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, NOTIF_LOG_FILE)
// Ограничиваем размер файла — перезаписываем если больше 100KB
if (f.exists() && f.length() > 100_000) f.writeText("")
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
f.appendText("$ts $msg\n")
} catch (_: Throwable) {}
}
}

View File

@@ -187,6 +187,49 @@ object CallManager {
ownPublicKey = publicKey.trim()
}
/**
* Вызывается из FCM push когда приходит type=call.
* Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала.
* Если WebSocket CALL придёт позже — дедупликация его отбросит.
*/
fun setIncomingFromPush(peerPublicKey: String, peerTitle: String) {
val peer = peerPublicKey.trim()
if (peer.isBlank()) return
// Уже в звонке — не перебиваем
if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}")
return
}
breadcrumb("setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle")
beginCallSession("incoming-push:${peer.take(8)}")
role = CallRole.CALLEE
resetRtcObjects()
val cachedInfo = ProtocolManager.getCachedUserInfo(peer)
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
val username = cachedInfo?.username.orEmpty()
setPeer(peer, title, username)
updateState {
it.copy(
phase = CallPhase.INCOMING,
statusText = "Incoming call..."
)
}
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, _state.value)
}
resolvePeerIdentity(peer)
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = scope.launch {
delay(INCOMING_RING_TIMEOUT_MS)
val pending = _state.value
if (pending.phase == CallPhase.INCOMING && pending.peerPublicKey == peer) {
breadcrumb("setIncomingFromPush: timeout → auto-decline")
declineIncomingCall()
}
}
}
fun startOutgoingCall(user: SearchUser): CallActionResult {
val targetKey = user.publicKey.trim()
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
@@ -322,25 +365,36 @@ object CallManager {
when (packet.signalType) {
SignalType.CALL -> {
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
// Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя
if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) {
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup")
resolvePeerIdentity(incomingPeer)
return
}
if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
val callerKey = packet.src.trim()
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL_BECAUSE_BUSY,
src = ownPublicKey,
dst = callerKey
dst = incomingPeer
)
}
return
}
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
role = CallRole.CALLEE
resetRtcObjects()
setPeer(incomingPeer, "", "")
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer)
val cachedTitle = cachedInfo?.title.orEmpty()
val cachedUsername = cachedInfo?.username.orEmpty()
setPeer(incomingPeer, cachedTitle, cachedUsername)
updateState {
it.copy(
phase = CallPhase.INCOMING,
@@ -348,6 +402,24 @@ object CallManager {
)
}
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
// Запускаем ForegroundService + IncomingCallActivity
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, _state.value)
// Пробуем запустить IncomingCallActivity напрямую
try {
val activityIntent = android.content.Intent(
ctx,
com.rosetta.messenger.IncomingCallActivity::class.java
).apply {
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
}
ctx.startActivity(activityIntent)
breadcrumb("IncomingCallActivity started from WebSocket OK")
} catch (e: Throwable) {
breadcrumb("IncomingCallActivity start FAILED: ${e.message} — relying on fullScreenIntent")
}
}
resolvePeerIdentity(incomingPeer)
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob =
@@ -869,6 +941,8 @@ object CallManager {
incomingRingTimeoutJob = null
setSpeakerphone(false)
_state.value = CallUiState()
// Останавливаем ForegroundService
appContext?.let { CallForegroundService.stop(it) }
}
private fun resetRtcObjects() {
@@ -1337,7 +1411,17 @@ object CallManager {
}
private fun updateState(reducer: (CallUiState) -> CallUiState) {
val old = _state.value
_state.update(reducer)
val newState = _state.value
// Синхронизируем ForegroundService при смене фазы или имени
// Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop()
if (newState.phase != CallPhase.IDLE &&
(newState.phase != old.phase || newState.displayName != old.displayName)) {
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, newState)
}
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }

View File

@@ -5,19 +5,26 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.util.Base64
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -41,8 +48,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages"
private const val CALL_CHANNEL_ID = "rosetta_calls_push"
private const val CALL_CHANNEL_NAME = "Calls"
private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call"
@@ -282,6 +287,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel()
// Резолвим имя и аватарку по publicKey
val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName
val avatarBitmap = loadAvatarBitmap(senderPublicKey)
val notifId = getNotificationIdForChat(senderPublicKey ?: "")
// Intent для открытия чата
@@ -302,12 +311,17 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentTitle(resolvedName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build()
val notificationManager =
@@ -336,6 +350,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel()
// Резолвим имя и аватарку по publicKey
val resolvedTitle = if (senderKey.isNotEmpty()) resolveNameForKey(senderKey) ?: title else title
val avatarBitmap = if (senderKey.isNotEmpty()) loadAvatarBitmap(senderKey) else null
// Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderKey)
@@ -359,11 +377,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentTitle(resolvedTitle)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build()
val notificationManager =
@@ -371,61 +394,69 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.notify(notifId, notification)
}
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */
/** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title")
wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return
if (!areNotificationsEnabled()) {
pushCallLog("SKIP: notifications disabled")
return
}
val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return
if (CallManager.state.value.phase != CallPhase.IDLE) return
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) {
pushCallLog("SKIP: dialog muted")
return
}
val currentPhase = CallManager.state.value.phase
if (currentPhase != CallPhase.IDLE) {
pushCallLog("SKIP: phase=$currentPhase (not IDLE)")
return
}
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
pushCallLog("SKIP: dedup blocked (delta=${now - lastTs}ms)")
return
}
lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel()
val resolvedName = resolveNameForKey(normalizedDialog) ?: title
pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush")
val notifId =
if (normalizedDialog.isNotEmpty()) {
getNotificationIdForChat(normalizedDialog)
} else {
("call:$title:$body").hashCode() and 0x7FFFFFFF
}
// Сразу ставим CallManager в INCOMING — не ждём WebSocket
CallManager.setIncomingFromPush(normalizedDialog, resolvedName)
pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}")
val openIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_chat", normalizedDialog)
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Пробуем запустить IncomingCallActivity напрямую из FCM
// На Android 10+ может быть заблокировано — тогда fullScreenIntent на нотификации сработает
try {
val activityIntent = android.content.Intent(
applicationContext,
com.rosetta.messenger.IncomingCallActivity::class.java
).apply {
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
}
applicationContext.startActivity(activityIntent)
pushCallLog("IncomingCallActivity started from FCM OK")
} catch (e: Throwable) {
pushCallLog("IncomingCallActivity start from FCM FAILED: ${e.message} — relying on fullScreenIntent")
}
}
val notification =
NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title.ifBlank { "Incoming call" })
.setContentText(body.ifBlank { "Incoming call" })
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
private fun pushCallLog(msg: String) {
Log.d(TAG, msg)
try {
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, "call_notification_log.txt")
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
f.appendText("$ts [FCM] $msg\n")
} catch (_: Throwable) {}
}
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
@@ -463,26 +494,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
}
/** Отдельный канал для входящих звонков */
private fun createCallNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
CALL_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Incoming call notifications"
enableVibration(true)
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
@@ -505,6 +516,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return null
}
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
/** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
@@ -519,4 +538,56 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
}.getOrDefault(false)
}
/** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */
private fun resolveNameForKey(publicKey: String?): String? {
if (publicKey.isNullOrBlank()) return null
// 1. In-memory cache
ProtocolManager.getCachedUserName(publicKey)?.let { return it }
// 2. DB dialogs table
return runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
if (account.isBlank()) return null
val db = RosettaDatabase.getDatabase(applicationContext)
val dialog = runBlocking(Dispatchers.IO) {
db.dialogDao().getDialog(account, publicKey)
}
dialog?.opponentTitle?.takeIf { it.isNotBlank() }
?: dialog?.opponentUsername?.takeIf { it.isNotBlank() }
}.getOrNull()
}
/** Получить аватарку как круглый Bitmap для notification по publicKey */
private fun loadAvatarBitmap(publicKey: String?): Bitmap? {
if (publicKey.isNullOrBlank()) return null
// Проверяем настройку
if (!isAvatarInNotificationsEnabled()) return null
return runCatching {
val db = RosettaDatabase.getDatabase(applicationContext)
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null
val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
// Делаем круглый bitmap для notification
toCircleBitmap(original)
}.getOrNull()
}
private fun toCircleBitmap(source: Bitmap): Bitmap {
val size = minOf(source.width, source.height)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(output)
val paint = android.graphics.Paint().apply {
isAntiAlias = true
}
val rect = android.graphics.Rect(0, 0, size, size)
canvas.drawARGB(0, 0, 0, 0)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
canvas.drawBitmap(source, rect, rect, paint)
return output
}
}

View File

@@ -94,8 +94,11 @@ import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.*
@@ -319,7 +322,8 @@ fun ChatDetailScreen(
chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false
isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {}
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
@@ -2302,6 +2306,19 @@ fun ChatDetailScreen(
}
)
}
// Баннер активного звонка (как в чат-листе)
val callUiState by CallManager.state.collectAsState()
val showCallBanner = callUiState.isVisible &&
callUiState.phase != CallPhase.INCOMING
if (showCallBanner) {
CallTopBanner(
state = callUiState,
onOpenCall = onOpenCallOverlay,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository
)
}
} // Закрытие Column topBar
},
containerColor = backgroundColor, // Фон всего чата

View File

@@ -845,6 +845,7 @@ fun ProfileScreen(
run {
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
TelegramToggleItem(
@@ -858,6 +859,18 @@ fun ProfileScreen(
},
isDarkTheme = isDarkTheme
)
TelegramToggleItem(
icon = TelegramIcons.Photos,
title = "Avatars in Notifications",
isEnabled = avatarInNotifications,
onToggle = {
scope.launch {
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
}
},
isDarkTheme = isDarkTheme
)
}
Spacer(modifier = Modifier.height(24.dp))

View File

@@ -582,7 +582,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillBounds
contentScale = ContentScale.Crop
)
}

View File

@@ -21,101 +21,45 @@ object ThemeWallpapers {
val all: List<ThemeWallpaper> =
listOf(
ThemeWallpaper(
id = "back_3",
name = "Wallpaper 1",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_3
),
ThemeWallpaper(
id = "back_4",
name = "Wallpaper 2",
id = "light_01",
name = "Light 1",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_4
drawableRes = R.drawable.wallpaper_light_01
),
ThemeWallpaper(
id = "back_5",
name = "Wallpaper 3",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_5
),
ThemeWallpaper(
id = "back_6",
name = "Wallpaper 4",
id = "light_02",
name = "Light 2",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_6
drawableRes = R.drawable.wallpaper_light_02
),
ThemeWallpaper(
id = "back_7",
name = "Wallpaper 5",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_7
),
ThemeWallpaper(
id = "back_8",
name = "Wallpaper 6",
id = "light_03",
name = "Light 3",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_8
),
ThemeWallpaper(
id = "back_9",
name = "Wallpaper 7",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_9
),
ThemeWallpaper(
id = "back_10",
name = "Wallpaper 8",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_4",
drawableRes = R.drawable.wallpaper_back_10
),
ThemeWallpaper(
id = "back_11",
name = "Wallpaper 9",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_11
),
ThemeWallpaper(
id = "back_1",
name = "Wallpaper 10",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_1
),
ThemeWallpaper(
id = "back_2",
name = "Wallpaper 11",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_4",
drawableRes = R.drawable.wallpaper_back_2
drawableRes = R.drawable.wallpaper_light_03
),
ThemeWallpaper(
id = "dark_01",
name = "Dark 1",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5",
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_dark_01
),
ThemeWallpaper(
id = "dark_02",
name = "Dark 2",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5",
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_dark_02
),
ThemeWallpaper(
id = "dark_03",
name = "Dark 3",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_6",
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_dark_03
)
)

View File

@@ -11,7 +11,9 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.core.view.WindowCompat
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.delay
@@ -86,7 +88,17 @@ fun RosettaAndroidTheme(
}
}
CompositionLocalProvider(LocalRosettaIsDarkTheme provides darkTheme) {
// Ограничиваем fontScale чтобы вёрстка не ломалась на телефонах с огромным масштабом текста
val currentDensity = LocalDensity.current
val cappedDensity = Density(
density = currentDensity.density,
fontScale = currentDensity.fontScale.coerceAtMost(1.3f)
)
CompositionLocalProvider(
LocalRosettaIsDarkTheme provides darkTheme,
LocalDensity provides cappedDensity
) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB