Релиз 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
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
@@ -43,14 +46,27 @@
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait">
|
||||
android:screenOrientation="portrait"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".IncomingCallActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.RosettaAndroid"
|
||||
android:launchMode="singleTask"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="com.rosetta.messenger.call" />
|
||||
|
||||
<!-- FileProvider for camera images -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -74,7 +90,7 @@
|
||||
<service
|
||||
android:name=".network.CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone|mediaPlayback" />
|
||||
android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
|
||||
|
||||
<!-- Firebase notification icon (optional, for better looking notifications) -->
|
||||
<meta-data
|
||||
|
||||
164
app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class PreferencesManager(private val context: Context) {
|
||||
val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
|
||||
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
|
||||
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
|
||||
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
|
||||
|
||||
// Chat Settings
|
||||
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large
|
||||
@@ -143,6 +144,11 @@ class PreferencesManager(private val context: Context) {
|
||||
preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
|
||||
}
|
||||
|
||||
val notificationAvatarEnabled: Flow<Boolean> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
|
||||
}
|
||||
|
||||
suspend fun setNotificationsEnabled(value: Boolean) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value }
|
||||
}
|
||||
@@ -159,6 +165,10 @@ class PreferencesManager(private val context: Context) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value }
|
||||
}
|
||||
|
||||
suspend fun setNotificationAvatarEnabled(value: Boolean) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATION_AVATAR_ENABLED] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 💬 CHAT SETTINGS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, // Фон всего чата
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,101 +21,45 @@ object ThemeWallpapers {
|
||||
val all: List<ThemeWallpaper> =
|
||||
listOf(
|
||||
ThemeWallpaper(
|
||||
id = "back_3",
|
||||
name = "Wallpaper 1",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_3
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_4",
|
||||
name = "Wallpaper 2",
|
||||
id = "light_01",
|
||||
name = "Light 1",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_4
|
||||
drawableRes = R.drawable.wallpaper_light_01
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_5",
|
||||
name = "Wallpaper 3",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_5
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_6",
|
||||
name = "Wallpaper 4",
|
||||
id = "light_02",
|
||||
name = "Light 2",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_6
|
||||
drawableRes = R.drawable.wallpaper_light_02
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_7",
|
||||
name = "Wallpaper 5",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_7
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_8",
|
||||
name = "Wallpaper 6",
|
||||
id = "light_03",
|
||||
name = "Light 3",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_8
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_9",
|
||||
name = "Wallpaper 7",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_9
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_10",
|
||||
name = "Wallpaper 8",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_10
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_11",
|
||||
name = "Wallpaper 9",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_11
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_1",
|
||||
name = "Wallpaper 10",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_1
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_2",
|
||||
name = "Wallpaper 11",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_2
|
||||
drawableRes = R.drawable.wallpaper_light_03
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "dark_01",
|
||||
name = "Dark 1",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_5",
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_dark_01
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "dark_02",
|
||||
name = "Dark 2",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_5",
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_dark_02
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "dark_03",
|
||||
name = "Dark 3",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_6",
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_dark_03
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 879 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_light_02.png
Normal file
|
After Width: | Height: | Size: 867 KiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_light_03.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |