Релиз 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" "cmake;3.22.1"
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$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 - name: Cache Gradle wrapper
uses: actions/cache@v3 uses: actions/cache@v3
@@ -112,8 +113,15 @@ jobs:
./gradlew --no-daemon --version ./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 - name: Build Release APK
run: ./gradlew --no-daemon assembleRelease run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease
- name: Check if APK exists - name: Check if APK exists
run: | run: |

View File

@@ -1,5 +1,26 @@
# Release Notes # 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 ## 1.3.4
### Звонки и UI ### Звонки и UI

View File

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

View File

@@ -10,8 +10,11 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <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" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -43,7 +46,9 @@
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait"> android:screenOrientation="portrait"
android:showWhenLocked="true"
android:turnScreenOn="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -51,6 +56,17 @@
</intent-filter> </intent-filter>
</activity> </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 --> <!-- FileProvider for camera images -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -74,7 +90,7 @@
<service <service
android:name=".network.CallForegroundService" android:name=".network.CallForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" /> android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
<!-- Firebase notification icon (optional, for better looking notifications) --> <!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data <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 package com.rosetta.messenger
// commit // commit
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -83,6 +85,10 @@ class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager private lateinit var accountManager: AccountManager
// Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth
// mutableStateOf чтобы Compose реагировал на изменение (избежать race condition)
private var openedForCall by mutableStateOf(false)
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
// Process-memory session cache: lets app return without password while process is alive. // 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
handleCallLockScreen(intent)
preferencesManager = PreferencesManager(this) preferencesManager = PreferencesManager(this)
accountManager = AccountManager(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() val scope = rememberCoroutineScope()
@@ -212,6 +234,9 @@ class MainActivity : FragmentActivity() {
showSplash -> "splash" showSplash -> "splash"
showOnboarding && hasExistingAccount == false -> showOnboarding && hasExistingAccount == false ->
"onboarding" "onboarding"
// При открытии по звонку с lock screen — пропускаем auth
openedForCall && hasExistingAccount == true ->
"main"
isLoggedIn != true && hasExistingAccount == false -> isLoggedIn != true && hasExistingAccount == false ->
"auth_new" "auth_new"
isLoggedIn != true && hasExistingAccount == true -> 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() { override fun onResume() {
super.onResume() super.onResume()
// 🔥 Приложение стало видимым - отключаем уведомления // 🔥 Приложение стало видимым - отключаем уведомления
@@ -1347,7 +1432,8 @@ fun MainScreen(
chatWallpaperId = chatWallpaperId, chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }, 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_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled") val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled") val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
// Chat Settings // Chat Settings
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large 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 preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
} }
val notificationAvatarEnabled: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(value: Boolean) { suspend fun setNotificationsEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value } 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 } 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 // 💬 CHAT SETTINGS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

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

View File

@@ -9,13 +9,26 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo 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.Build
import android.os.IBinder import android.os.IBinder
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R 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. * 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext) CallManager.initialize(applicationContext)
notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}")
when (action) { when (action) {
ACTION_STOP -> { ACTION_STOP -> {
notifLog("ACTION_STOP → stopSelf")
stopForegroundCompat() stopForegroundCompat()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_END -> { ACTION_END -> {
notifLog("ACTION_END → endCall")
CallManager.endCall() CallManager.endCall()
stopForegroundCompat() stopForegroundCompat()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_DECLINE -> { 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() stopForegroundCompat()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_ACCEPT -> { ACTION_ACCEPT -> {
val result = CallManager.acceptIncomingCall() notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}")
if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) { // Если push пришёл раньше WebSocket — CallManager ещё в IDLE.
openCallUi() // Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket.
} else { CoroutineScope(Dispatchers.Main).launch {
Log.w(TAG, "Accept action ignored: $result") 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 else -> Unit
} }
val snapshot = extractSnapshot(intent) val snapshot = extractSnapshot(intent)
notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}")
if (snapshot.phase == CallPhase.IDLE) { if (snapshot.phase == CallPhase.IDLE) {
notifLog("phase=IDLE → stopSelf")
stopForegroundCompat() stopForegroundCompat()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
@@ -74,7 +121,18 @@ class CallForegroundService : Service() {
ensureNotificationChannel() ensureNotificationChannel()
val notification = buildNotification(snapshot) val notification = buildNotification(snapshot)
val hasFullScreen = snapshot.phase == CallPhase.INCOMING
notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground")
startForegroundCompat(notification, snapshot.phase) 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 return START_STICKY
} }
@@ -110,29 +168,40 @@ class CallForegroundService : Service() {
private fun ensureNotificationChannel() { private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID) if (manager.getNotificationChannel(CHANNEL_ID) != null) return
if (existing != null) return
val channel = val channel =
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Calls", "Calls",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_MAX
).apply { ).apply {
description = "Ongoing call controls" description = "Incoming and ongoing calls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false) setShowBadge(false)
enableVibration(true)
vibrationPattern = longArrayOf(0, 1000, 500, 1000)
setBypassDnd(true)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
private fun buildNotification(snapshot: Snapshot): Notification { 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( val openAppPendingIntent = PendingIntent.getActivity(
this, this,
REQUEST_OPEN_APP, 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 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 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -155,6 +224,18 @@ class CallForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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 = val defaultStatus =
when (snapshot.phase) { when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call" CallPhase.INCOMING -> "Incoming call"
@@ -164,9 +245,14 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended" CallPhase.IDLE -> "Call ended"
} }
val contentText = snapshot.statusText.ifBlank { defaultStatus } val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 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 = val style =
if (snapshot.phase == CallPhase.INCOMING) { if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall( Notification.CallStyle.forIncomingCall(
@@ -188,6 +274,11 @@ class CallForegroundService : Service() {
.setVisibility(Notification.VISIBILITY_PUBLIC) .setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style) .setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply { .apply {
if (snapshot.phase == CallPhase.ACTIVE) { if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true) setUsesChronometer(true)
@@ -200,6 +291,7 @@ class CallForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName) .setContentTitle(snapshot.displayName)
.apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) }
.setContentText(contentText) .setContentText(contentText)
.setContentIntent(openAppPendingIntent) .setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -207,6 +299,11 @@ class CallForegroundService : Service() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setOngoing(true) .setOngoing(true)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply { .apply {
if (snapshot.phase == CallPhase.INCOMING) { if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent) addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
@@ -251,8 +348,10 @@ class CallForegroundService : Service() {
private fun startForegroundTyped(notification: Notification, type: Int): Boolean { private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try { return try {
startForeground(NOTIFICATION_ID, notification, type) startForeground(NOTIFICATION_ID, notification, type)
notifLog("startForeground OK type=$type")
true true
} catch (error: Throwable) { } catch (error: Throwable) {
notifLog("startForeground FAILED type=$type: ${error.message}")
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}") Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false false
} }
@@ -261,8 +360,10 @@ class CallForegroundService : Service() {
private fun startForegroundUntyped(notification: Notification): Boolean { private fun startForegroundUntyped(notification: Notification): Boolean {
return try { return try {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notifLog("startForeground (untyped) OK")
true true
} catch (error: Throwable) { } catch (error: Throwable) {
notifLog("startForeground (untyped) FAILED: ${error.message}")
Log.w(TAG, "Untyped startForeground failed: ${error.message}") Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false false
} }
@@ -285,6 +386,8 @@ class CallForegroundService : Service() {
private const val REQUEST_END_CALL = 9012 private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013 private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014 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_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END" 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() { private fun openCallUi() {
notifLog("openCallUi → MainActivity")
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP 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) putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
} }
runCatching { startActivity(intent) } 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() 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 { fun startOutgoingCall(user: SearchUser): CallActionResult {
val targetKey = user.publicKey.trim() val targetKey = user.publicKey.trim()
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
@@ -322,25 +365,36 @@ object CallManager {
when (packet.signalType) { when (packet.signalType) {
SignalType.CALL -> { 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) { if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY") breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
val callerKey = packet.src.trim() if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL_BECAUSE_BUSY, signalType = SignalType.END_CALL_BECAUSE_BUSY,
src = ownPublicKey, src = ownPublicKey,
dst = callerKey dst = incomingPeer
) )
} }
return return
} }
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
beginCallSession("incoming:${incomingPeer.take(8)}") beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
setPeer(incomingPeer, "", "") // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer)
val cachedTitle = cachedInfo?.title.orEmpty()
val cachedUsername = cachedInfo?.username.orEmpty()
setPeer(incomingPeer, cachedTitle, cachedUsername)
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.INCOMING, phase = CallPhase.INCOMING,
@@ -348,6 +402,24 @@ object CallManager {
) )
} }
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } 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) resolvePeerIdentity(incomingPeer)
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = incomingRingTimeoutJob =
@@ -869,6 +941,8 @@ object CallManager {
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
setSpeakerphone(false) setSpeakerphone(false)
_state.value = CallUiState() _state.value = CallUiState()
// Останавливаем ForegroundService
appContext?.let { CallForegroundService.stop(it) }
} }
private fun resetRtcObjects() { private fun resetRtcObjects() {
@@ -1337,7 +1411,17 @@ object CallManager {
} }
private fun updateState(reducer: (CallUiState) -> CallUiState) { private fun updateState(reducer: (CallUiState) -> CallUiState) {
val old = _state.value
_state.update(reducer) _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) } 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.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -41,8 +48,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "RosettaFCM" private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages" private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "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_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message" private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call" private const val PUSH_TYPE_CALL = "call"
@@ -282,6 +287,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Резолвим имя и аватарку по publicKey
val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName
val avatarBitmap = loadAvatarBitmap(senderPublicKey)
val notifId = getNotificationIdForChat(senderPublicKey ?: "") val notifId = getNotificationIdForChat(senderPublicKey ?: "")
// Intent для открытия чата // Intent для открытия чата
@@ -302,12 +311,17 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification = val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName) .setContentTitle(resolvedName)
.setContentText(messagePreview) .setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build() .build()
val notificationManager = val notificationManager =
@@ -336,6 +350,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() 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 мог убрать уведомление // Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (senderKey.isNotEmpty()) { val notifId = if (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderKey) getNotificationIdForChat(senderKey)
@@ -359,11 +377,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification = val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(resolvedTitle)
.setContentText(body) .setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build() .build()
val notificationManager = val notificationManager =
@@ -371,61 +394,69 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.notify(notifId, notification) notificationManager.notify(notifId, notification)
} }
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */ /** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) { private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title")
wakeProtocolFromPush("call") wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return if (!areNotificationsEnabled()) {
pushCallLog("SKIP: notifications disabled")
return
}
val normalizedDialog = dialogKey.trim() val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) {
if (CallManager.state.value.phase != CallPhase.IDLE) return 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 dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] 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 lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel() val resolvedName = resolveNameForKey(normalizedDialog) ?: title
pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush")
val notifId = // Сразу ставим CallManager в INCOMING — не ждём WebSocket
if (normalizedDialog.isNotEmpty()) { CallManager.setIncomingFromPush(normalizedDialog, resolvedName)
getNotificationIdForChat(normalizedDialog) pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}")
} else {
("call:$title:$body").hashCode() and 0x7FFFFFFF
}
val openIntent = // Пробуем запустить IncomingCallActivity напрямую из FCM
Intent(this, MainActivity::class.java).apply { // На Android 10+ может быть заблокировано — тогда fullScreenIntent на нотификации сработает
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP try {
putExtra("open_chat", normalizedDialog) val activityIntent = android.content.Intent(
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) applicationContext,
} com.rosetta.messenger.IncomingCallActivity::class.java
val pendingIntent = ).apply {
PendingIntent.getActivity( flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
this, android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
notifId, }
openIntent, applicationContext.startActivity(activityIntent)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE pushCallLog("IncomingCallActivity started from FCM OK")
) } catch (e: Throwable) {
pushCallLog("IncomingCallActivity start from FCM FAILED: ${e.message} — relying on fullScreenIntent")
}
}
val notification = private fun pushCallLog(msg: String) {
NotificationCompat.Builder(this, CALL_CHANNEL_ID) Log.d(TAG, msg)
.setSmallIcon(R.drawable.ic_notification) try {
.setContentTitle(title.ifBlank { "Incoming call" }) val dir = java.io.File(applicationContext.filesDir, "crash_reports")
.setContentText(body.ifBlank { "Incoming call" }) if (!dir.exists()) dir.mkdirs()
.setPriority(NotificationCompat.PRIORITY_MAX) val f = java.io.File(dir, "call_notification_log.txt")
.setCategory(NotificationCompat.CATEGORY_CALL) val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) f.appendText("$ts [FCM] $msg\n")
.setAutoCancel(true) } catch (_: Throwable) {}
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
} }
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */ /** Пробуждаем сетевой слой по 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 */ /** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) { private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
@@ -505,6 +516,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return null return null
} }
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
/** Проверка: замьючен ли диалог для текущего аккаунта */ /** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean { private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false if (senderPublicKey.isBlank()) return false
@@ -519,4 +538,56 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
}.getOrDefault(false) }.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.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType 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.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
@@ -319,7 +322,8 @@ fun ChatDetailScreen(
chatWallpaperId: String = "", chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {}, onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {}
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current 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 } // Закрытие Column topBar
}, },
containerColor = backgroundColor, // Фон всего чата containerColor = backgroundColor, // Фон всего чата

View File

@@ -845,6 +845,7 @@ fun ProfileScreen(
run { run {
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true) val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
TelegramToggleItem( TelegramToggleItem(
@@ -858,6 +859,18 @@ fun ProfileScreen(
}, },
isDarkTheme = isDarkTheme 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)) Spacer(modifier = Modifier.height(24.dp))

View File

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

View File

@@ -21,101 +21,45 @@ object ThemeWallpapers {
val all: List<ThemeWallpaper> = val all: List<ThemeWallpaper> =
listOf( listOf(
ThemeWallpaper( ThemeWallpaper(
id = "back_3", id = "light_01",
name = "Wallpaper 1", name = "Light 1",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_3
),
ThemeWallpaper(
id = "back_4",
name = "Wallpaper 2",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_1", pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_4 drawableRes = R.drawable.wallpaper_light_01
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_5", id = "light_02",
name = "Wallpaper 3", name = "Light 2",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_5
),
ThemeWallpaper(
id = "back_6",
name = "Wallpaper 4",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2", pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_6 drawableRes = R.drawable.wallpaper_light_02
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_7", id = "light_03",
name = "Wallpaper 5", name = "Light 3",
preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_7
),
ThemeWallpaper(
id = "back_8",
name = "Wallpaper 6",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_3", pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_8 drawableRes = R.drawable.wallpaper_light_03
),
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
), ),
ThemeWallpaper( ThemeWallpaper(
id = "dark_01", id = "dark_01",
name = "Dark 1", name = "Dark 1",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5", pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_dark_01 drawableRes = R.drawable.wallpaper_dark_01
), ),
ThemeWallpaper( ThemeWallpaper(
id = "dark_02", id = "dark_02",
name = "Dark 2", name = "Dark 2",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5", pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_dark_02 drawableRes = R.drawable.wallpaper_dark_02
), ),
ThemeWallpaper( ThemeWallpaper(
id = "dark_03", id = "dark_03",
name = "Dark 3", name = "Dark 3",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_6", pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_dark_03 drawableRes = R.drawable.wallpaper_dark_03
) )
) )

View File

@@ -11,7 +11,9 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.rosetta.messenger.ui.utils.NavigationModeUtils import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.delay 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, 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