Релиз 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"
|
"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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,14 +46,27 @@
|
|||||||
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" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</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
|
||||||
|
|||||||
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
|
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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, // Фон всего чата
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
|
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 |