diff --git a/.gitea/workflows/android.yaml b/.gitea/workflows/android.yaml
index 8a0bf8f..f474d21 100644
--- a/.gitea/workflows/android.yaml
+++ b/.gitea/workflows/android.yaml
@@ -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: |
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9f660bb..ca23299 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f397406..5f6e111 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.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 {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b2dea42..1503d11 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,8 +10,11 @@
+
+
+
@@ -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">
-
+
+
+
+ android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
= 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}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt
index 9eefbf8..2b5e414 100644
--- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt
+++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt
@@ -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 }
)
}
}
diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
index aa02e85..8879c92 100644
--- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt
@@ -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 =
+ 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
// ═════════════════════════════════════════════════════════════
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 9331692..3c6a07d 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,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 =
diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt
index d78e1e6..8a89d2d 100644
--- a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt
@@ -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) {}
}
}
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 31eda57..5c43352 100644
--- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
+++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt
@@ -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) }
diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
index 3efd8b2..c062a48 100644
--- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
+++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
@@ -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
+ }
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
index 8c097b3..dd32e27 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
@@ -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, // Фон всего чата
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
index 64ea794..ebe2a15 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
@@ -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))
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
index eaecbff..1e46910 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt
@@ -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
)
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt
index 471905e..8fc74e6 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt
@@ -21,101 +21,45 @@ object ThemeWallpapers {
val all: List =
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
)
)
diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt
index e88589b..8f68fd5 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt
@@ -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,
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_1.png b/app/src/main/res/drawable-nodpi/wallpaper_back_1.png
deleted file mode 100644
index b490b49..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_1.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_10.png b/app/src/main/res/drawable-nodpi/wallpaper_back_10.png
deleted file mode 100644
index 482c8b1..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_10.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_11.png b/app/src/main/res/drawable-nodpi/wallpaper_back_11.png
deleted file mode 100644
index 0518932..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_11.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_2.png b/app/src/main/res/drawable-nodpi/wallpaper_back_2.png
deleted file mode 100644
index 4946de6..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_2.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_3.png b/app/src/main/res/drawable-nodpi/wallpaper_back_3.png
deleted file mode 100644
index b490b49..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_3.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_4.png b/app/src/main/res/drawable-nodpi/wallpaper_back_4.png
deleted file mode 100644
index b8cd264..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_4.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_5.png b/app/src/main/res/drawable-nodpi/wallpaper_back_5.png
deleted file mode 100644
index 3ec7b78..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_5.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_6.png b/app/src/main/res/drawable-nodpi/wallpaper_back_6.png
deleted file mode 100644
index ac9297f..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_6.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_7.png b/app/src/main/res/drawable-nodpi/wallpaper_back_7.png
deleted file mode 100644
index 4df23a3..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_7.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_8.png b/app/src/main/res/drawable-nodpi/wallpaper_back_8.png
deleted file mode 100644
index 9994940..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_8.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_9.png b/app/src/main/res/drawable-nodpi/wallpaper_back_9.png
deleted file mode 100644
index 1490358..0000000
Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_9.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_dark_04.png b/app/src/main/res/drawable-nodpi/wallpaper_light_01.png
similarity index 100%
rename from app/src/main/res/drawable-nodpi/wallpaper_dark_04.png
rename to app/src/main/res/drawable-nodpi/wallpaper_light_01.png
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_02.png b/app/src/main/res/drawable-nodpi/wallpaper_light_02.png
new file mode 100644
index 0000000..dd3c8be
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_light_02.png differ
diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png
new file mode 100644
index 0000000..c69cdca
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png differ